diff options
-rw-r--r-- | README | 6 | ||||
-rw-r--r-- | ranger/container/settingobject.py | 1 | ||||
-rw-r--r-- | ranger/core/actions.py | 114 | ||||
-rw-r--r-- | ranger/core/fm.py | 22 | ||||
-rw-r--r-- | ranger/core/loader.py | 103 | ||||
-rw-r--r-- | ranger/core/main.py | 1 | ||||
-rw-r--r-- | ranger/core/shared.py | 7 | ||||
-rwxr-xr-x | ranger/data/scope.sh | 59 | ||||
-rw-r--r-- | ranger/defaults/options.py | 7 | ||||
-rw-r--r-- | ranger/ext/shutil_generatorized.py | 302 | ||||
-rw-r--r-- | ranger/fsobject/directory.py | 3 | ||||
-rw-r--r-- | ranger/fsobject/file.py | 27 | ||||
-rw-r--r-- | ranger/fsobject/fsobject.py | 4 | ||||
-rw-r--r-- | ranger/gui/ansi.py | 94 | ||||
-rw-r--r-- | ranger/gui/curses_shortcuts.py | 9 | ||||
-rw-r--r-- | ranger/gui/widgets/browsercolumn.py | 3 | ||||
-rw-r--r-- | ranger/gui/widgets/pager.py | 29 | ||||
-rw-r--r-- | test/tc_ansi.py | 40 |
18 files changed, 469 insertions, 362 deletions
diff --git a/README b/README index 5534a26a..44c03513 100644 --- a/README +++ b/README @@ -59,6 +59,12 @@ Optional: * The "file" program * A pager ("less" by default) +For scope.sh: (enhanced file previews) +* img2txt (from caca-utils) for previewing images +* highlight for syntax highlighting of code +* atool for previews of archives +* lynx or elinks for previews of html pages + Getting Started --------------- diff --git a/ranger/container/settingobject.py b/ranger/container/settingobject.py index 008b846d..c8bd8b49 100644 --- a/ranger/container/settingobject.py +++ b/ranger/container/settingobject.py @@ -35,6 +35,7 @@ ALLOWED_SETTINGS = { 'mouse_enabled': bool, 'preview_directories': bool, 'preview_files': bool, + 'preview_script': (str, type(None)), 'save_console_history': bool, 'scroll_offset': int, 'shorten_title': int, # Note: False is an instance of int diff --git a/ranger/core/actions.py b/ranger/core/actions.py index 4b3752ef..5adf42c9 100644 --- a/ranger/core/actions.py +++ b/ranger/core/actions.py @@ -29,8 +29,7 @@ from ranger import fsobject from ranger.core.shared import FileManagerAware, EnvironmentAware, \ SettingsAware from ranger.fsobject import File -from ranger.ext import shutil_generatorized as shutil_g -from ranger.core.loader import LoadableObject +from ranger.core.loader import CommandLoader class _MacroTemplate(string.Template): """A template for substituting macros in commands""" @@ -52,6 +51,7 @@ class Actions(FileManagerAware, EnvironmentAware, SettingsAware): def reset(self): """Reset the filemanager, clearing the directory buffer""" old_path = self.env.cwd.path + self.previews = {} self.env.garbage_collect(-1) self.enter_dir(old_path) @@ -569,13 +569,68 @@ class Actions(FileManagerAware, EnvironmentAware, SettingsAware): if not hasattr(self.ui, 'open_embedded_pager'): return - try: - f = open(self.env.cf.path, 'r') - except: - pass + pager = self.ui.open_embedded_pager() + pager.set_source(self.env.cf.get_preview_source(pager.wid, pager.hei)) + + # -------------------------- + # -- Previews + # -------------------------- + def get_preview(self, path, width, height): + if self.settings.preview_script: + # self.previews is a 2 dimensional dict: + # self.previews['/tmp/foo.jpg'][(80, 24)] = "the content..." + # self.previews['/tmp/foo.jpg']['loading'] = False + # A -1 in tuples means "any"; (80, -1) = wid. of 80 and any hei. + try: + data = self.previews[path] + except: + data = self.previews[path] = {'loading': False} + else: + if data['loading']: + return None + + found = data.get((-1, -1), data.get((width, -1), + data.get((-1, height), data.get((width, height), False)))) + if found == False: + data['loading'] = True + loadable = CommandLoader(args=[self.settings.preview_script, + path, str(width), str(height)], read=True, + silent=True, descr="Getting preview of %s" % path) + def on_after(signal): + self.notify("%s complete" % path) + exit = signal.process.poll() + content = signal.loader.stdout_buffer + content += signal.process.stdout.read() + if exit == 0: + data[(width, height)] = content + elif exit == 3: + data[(-1, height)] = content + elif exit == 4: + data[(width, -1)] = content + elif exit == 5: + data[(-1, -1)] = content + else: + data[(-1, -1)] = None # XXX + if self.env.cf.path == path: + self.ui.browser.pager.need_redraw = True + self.ui.browser.need_redraw = True + data['loading'] = False + def on_destroy(signal): + try: + del self.previews[path] + except: + pass + loadable.signal_bind('after', on_after) + loadable.signal_bind('destroy', on_destroy) + self.loader.add(loadable) + return None + else: + return found else: - pager = self.ui.open_embedded_pager() - pager.set_source(f) + try: + return open(path, 'r') + except: + return None # -------------------------- # -- Tabs @@ -685,11 +740,12 @@ class Actions(FileManagerAware, EnvironmentAware, SettingsAware): if not copied_files: return + def refresh(_): + cwd = self.env.get_directory(original_path) + cwd.load_content() + original_path = self.env.cwd.path - try: - one_file = copied_files[0] - except: - one_file = None + one_file = copied_files[0] if self.env.cut: self.env.copy.clear() @@ -698,36 +754,20 @@ class Actions(FileManagerAware, EnvironmentAware, SettingsAware): descr = "moving: " + one_file.path else: descr = "moving files from: " + one_file.dirname - def generate(): - for f in copied_files: - for _ in shutil_g.move(src=f.path, - dst=original_path, - overwrite=overwrite): - yield - cwd = self.env.get_directory(original_path) - cwd.load_content() + obj = CommandLoader(args=['mv', '--backup=existing', + '--suffix=_', '-ft', self.env.cwd.path] + \ + [f.path for f in copied_files], descr=descr) else: if len(copied_files) == 1: descr = "copying: " + one_file.path else: descr = "copying files from: " + one_file.dirname - def generate(): - for f in self.env.copy: - if isdir(f.path): - for _ in shutil_g.copytree(src=f.path, - dst=join(original_path, f.basename), - symlinks=True, - overwrite=overwrite): - yield - else: - for _ in shutil_g.copy2(f.path, original_path, - symlinks=True, - overwrite=overwrite): - yield - cwd = self.env.get_directory(original_path) - cwd.load_content() - - self.loader.add(LoadableObject(generate(), descr)) + obj = CommandLoader(args=['cp', '--backup=existing', '--archive', + '--suffix=_', '-frt', self.env.cwd.path] + \ + [f.path for f in self.env.copy], descr=descr) + + obj.signal_bind('after', refresh) + self.loader.add(obj) def delete(self): self.notify("Deleting!", duration=1) diff --git a/ranger/core/fm.py b/ranger/core/fm.py index 5e01f96f..bfcebb92 100644 --- a/ranger/core/fm.py +++ b/ranger/core/fm.py @@ -50,6 +50,7 @@ class FM(Actions, SignalDispatcher): self.bookmarks = bookmarks self.tags = tags self.tabs = {} + self.previews = {} self.current_tab = 1 self.loader = Loader() @@ -97,6 +98,21 @@ class FM(Actions, SignalDispatcher): mimetypes.knownfiles.append(self.relpath('data/mime.types')) self.mimetypes = mimetypes.MimeTypes() + def destroy(self): + debug = ranger.arg.debug + if self.ui: + try: + self.ui.destroy() + except: + if debug: + raise + if self.loader: + try: + self.loader.destroy() + except: + if debug: + raise + def block_input(self, sec=0): self.input_blocked = sec != 0 self.input_blocked_until = time() + sec @@ -106,6 +122,12 @@ class FM(Actions, SignalDispatcher): self.input_blocked = False return self.input_blocked + def copy_config_files(self): + if not (ranger.arg.clean or os.path.exists(self.confpath('scope.sh'))): + import shutil + shutil.copy(self.relpath('data/scope.sh'), + self.confpath('scope.sh')) + def confpath(self, *paths): """returns the path relative to rangers configuration directory""" if ranger.arg.clean: diff --git a/ranger/core/loader.py b/ranger/core/loader.py index 7b074d60..28171042 100644 --- a/ranger/core/loader.py +++ b/ranger/core/loader.py @@ -14,11 +14,18 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. from collections import deque +from time import time, sleep +from subprocess import Popen, PIPE from time import time from ranger.core.shared import FileManagerAware +from ranger.ext.signal_dispatcher import SignalDispatcher import math +import os +import select -class LoadableObject(object): + +class Loadable(object): + paused = False def __init__(self, gen, descr): self.load_generator = gen self.description = descr @@ -26,6 +33,90 @@ class LoadableObject(object): def get_description(self): return self.description + def pause(self): + self.paused = True + + def unpause(self): + try: + del self.paused + except: + pass + + def destroy(self): + pass + + +class CommandLoader(Loadable, SignalDispatcher, FileManagerAware): + """ + Run an external command with the loader. + + Output from stderr will be reported. Ensure that the process doesn't + ever ask for input, otherwise the loader will be blocked until this + object is removed from the queue (type ^C in ranger) + """ + finished = False + process = None + def __init__(self, args, descr, silent=False, read=False): + SignalDispatcher.__init__(self) + Loadable.__init__(self, self.generate(), descr) + self.args = args + self.silent = silent + self.read = read + self.stdout_buffer = "" + + def generate(self): + self.process = process = Popen(self.args, + stdout=PIPE, stderr=PIPE) + self.signal_emit('before', process=process, loader=self) + if self.silent and not self.read: + while process.poll() is None: + yield + sleep(0.03) + else: + selectlist = [] + if self.read: + selectlist.append(process.stdout) + if not self.silent: + selectlist.append(process.stderr) + while process.poll() is None: + yield + try: + rd, _, __ = select.select(selectlist, [], [], 0.03) + if rd: + rd = rd[0] + read = rd.read(512) + if rd == process.stderr and read: + self.fm.notify(read, bad=True) + elif rd == process.stdout and read: + self.stdout_buffer += read + except select.error: + sleep(0.03) + self.finished = True + self.signal_emit('after', process=process, loader=self) + + def pause(self): + if not self.finished and not self.paused: + try: + self.process.send_signal(20) + except: + pass + Loadable.pause(self) + self.signal_emit('pause', process=self.process, loader=self) + + def unpause(self): + if not self.finished and self.paused: + try: + self.process.send_signal(18) + except: + pass + Loadable.unpause(self) + self.signal_emit('unpause', process=self.process, loader=self) + + def destroy(self): + self.signal_emit('destroy', process=self.process, loader=self) + if self.process: + self.process.kill() + class Loader(FileManagerAware): seconds_of_work_time = 0.03 @@ -65,6 +156,8 @@ class Loader(FileManagerAware): if to == 0: self.queue.appendleft(item) + if _from != 0: + self.queue[1].pause() elif to == -1: self.queue.append(item) else: @@ -84,6 +177,7 @@ class Loader(FileManagerAware): item = self.queue[index] if hasattr(item, 'unload'): item.unload() + item.destroy() del self.queue[index] def work(self): @@ -104,7 +198,10 @@ class Loader(FileManagerAware): self.rotate() if item != self.old_item: + if self.old_item: + self.old_item.pause() self.old_item = item + item.unpause() end_time = time() + self.seconds_of_work_time @@ -120,3 +217,7 @@ class Loader(FileManagerAware): def has_work(self): """Is there anything to load?""" return bool(self.queue) + + def destroy(self): + while self.queue: + self.queue.pop().destroy() diff --git a/ranger/core/main.py b/ranger/core/main.py index 42118516..ed555a8d 100644 --- a/ranger/core/main.py +++ b/ranger/core/main.py @@ -65,6 +65,7 @@ def main(): # Initialize objects EnvironmentAware.env = Environment(target) fm = FM() + fm.copy_config_files() fm.tabs = dict((n+1, os.path.abspath(path)) for n, path \ in enumerate(targets[:9])) load_settings(fm, arg.clean) diff --git a/ranger/core/shared.py b/ranger/core/shared.py index b91445a3..1d0f96a0 100644 --- a/ranger/core/shared.py +++ b/ranger/core/shared.py @@ -54,6 +54,13 @@ class SettingsAware(Awareness): settings.signal_bind('setopt.colorscheme', _colorscheme_name_to_class, priority=1) + def postprocess_paths(signal): + if isinstance(signal.value, str): + import os + signal.value = os.path.expanduser(signal.value) + settings.signal_bind('setopt.preview_script', + postprocess_paths, priority=1) + if not clean: # add the custom options to the list of setting sources sys.path[0:0] = [ranger.arg.confdir] diff --git a/ranger/data/scope.sh b/ranger/data/scope.sh new file mode 100755 index 00000000..788ec33c --- /dev/null +++ b/ranger/data/scope.sh @@ -0,0 +1,59 @@ +#!/bin/bash + +# This script is called whenever you preview a file. +# Its output is used as the preview. ANSI color codes are supported. + +# NOTES: This script is considered a configuration file. If you upgrade +# ranger, it will be left untouched. (You must update it yourself) +# NEVER make this script interactive. (by starting mplayer or something) + +# Meanings of exit codes: +# code | meaning | action of ranger +# -----+------------+------------------------------------------- +# 0 | success | display stdout as preview +# 1 | no preview | display no preview at all +# 2 | plain text | display the plain content of the file +# 3 | fix width | success. Don't reload when width changes +# 4 | fix height | success. Don't reload when height changes +# 5 | fix both | success. Don't ever reload + +# Meaningful aliases for arguments: +path="$1" # Full path of the selected file +width="$2" # Width of the preview pane (number of fitting characters) +height="$3" # Height of the preview pane (number of fitting characters) + +# Find out something about the file: +mimetype=$(file --mime-type -Lb "$path") +extension=$(echo "$path" | grep '\.' | grep -o '[^.]\+$') + +# Other useful stuff +maxln=200 # print up to $maxln lines +function have { type -P "$1" > /dev/null; } # test if program is installed + +case "$extension" in + # Archive extensions: + 7z|a|ace|alz|arc|arj|bz|bz2|cab|cpio|deb|gz|jar|lha|lz|lzh|lzma|lzo|\ + rar|rpm|rz|t7z|tar|tbz|tbz2|tgz|tlz|txz|tZ|tzo|war|xz|Z|zip) + atool -l "$path" | head -n $maxln && exit 3 + exit 1;; + pdf) + pdftotext -q "$path" - | head -n $maxln && exit 3 + exit 1;; + # HTML Pages: + htm|html|xhtml) + have lynx && lynx -dump "$path" | head -n $maxln && exit 5 + have elinks && elinks -dump "$path" | head -n $maxln && exit 5 + ;; # fall back to highlight/cat if theres no lynx/elinks +esac + +case "$mimetype" in + # Syntax highlight for text files: + text/* | */xml) + (highlight --ansi "$path" || cat "$path") | head -n $maxln + exit 5;; + # Ascii-previews of images: + image/*) + img2txt --gamma=0.6 --width="$width" "$path" && exit 4 || exit 1;; +esac + +exit 1 diff --git a/ranger/defaults/options.py b/ranger/defaults/options.py index 126d4bad..3c20c6fb 100644 --- a/ranger/defaults/options.py +++ b/ranger/defaults/options.py @@ -39,6 +39,13 @@ hidden_filter = regexp( r'^\.|\.(?:pyc|pyo|bak|swp)$|~$|lost\+found') show_hidden = False +# Which script is used to generate file previews? +#preview_script = None + +# Ranger ships with scope.sh, a script that calls external programs (see +# README for dependencies) to preview images, archives, etc. +preview_script = '~/.config/ranger/scope.sh' + # Show dotfiles in the bookmark preview box? show_hidden_bookmarks = True diff --git a/ranger/ext/shutil_generatorized.py b/ranger/ext/shutil_generatorized.py deleted file mode 100644 index 436d2cb7..00000000 --- a/ranger/ext/shutil_generatorized.py +++ /dev/null @@ -1,302 +0,0 @@ -# This file was taken from the python standard library and has been -# slightly modified to do a "yield" after every 16KB of copying -"""Utility functions for copying files and directory trees. - -XXX The functions here don't copy the resource fork or other metadata on Mac. - -""" - -import os -import sys -import stat -from os.path import abspath - -__all__ = ["copyfileobj","copyfile","copystat","copy2", - "copytree","move","rmtree","Error", "SpecialFileError"] - -APPENDIX = '_' - -class Error(EnvironmentError): - pass - -class SpecialFileError(EnvironmentError): - """Raised when trying to do a kind of operation (e.g. copying) which is - not supported on a special file (e.g. a named pipe)""" - -try: - WindowsError -except NameError: - WindowsError = None - -def copyfileobj(fsrc, fdst, length=16*1024): - """copy data from file-like object fsrc to file-like object fdst""" - while 1: - buf = fsrc.read(length) - if not buf: - break - fdst.write(buf) - yield - -def _samefile(src, dst): - # Macintosh, Unix. - if hasattr(os.path,'samefile'): - try: - return os.path.samefile(src, dst) - except OSError: - return False - - # All other platforms: check for same pathname. - return (os.path.normcase(abspath(src)) == - os.path.normcase(abspath(dst))) - -def copyfile(src, dst): - """Copy data from src to dst""" - if _samefile(src, dst): - raise Error("`%s` and `%s` are the same file" % (src, dst)) - - fsrc = None - fdst = None - for fn in [src, dst]: - try: - st = os.stat(fn) - except OSError: - # File most likely does not exist - pass - else: - # XXX What about other special files? (sockets, devices...) - if stat.S_ISFIFO(st.st_mode): - raise SpecialFileError("`%s` is a named pipe" % fn) - try: - fsrc = open(src, 'rb') - fdst = open(dst, 'wb') - for _ in copyfileobj(fsrc, fdst): - yield - finally: - if fdst: - fdst.close() - if fsrc: - fsrc.close() - -def copystat(src, dst): - """Copy all stat info (mode bits, atime, mtime, flags) from src to dst""" - st = os.stat(src) - mode = stat.S_IMODE(st.st_mode) - if hasattr(os, 'utime'): - try: os.utime(dst, (st.st_atime, st.st_mtime)) - except: pass - if hasattr(os, 'chmod'): - try: os.chmod(dst, mode) - except: pass - if hasattr(os, 'chflags') and hasattr(st, 'st_flags'): - try: os.chflags(dst, st.st_flags) - except: pass - -def copy2(src, dst, overwrite=False, symlinks=False): - """Copy data and all stat info ("cp -p src dst"). - - The destination may be a directory. - - """ - if os.path.isdir(dst): - dst = os.path.join(dst, os.path.basename(src)) - if not overwrite: - dst = get_safe_path(dst) - if symlinks and os.path.islink(src): - linkto = os.readlink(src) - os.symlink(linkto, dst) - else: - for _ in copyfile(src, dst): - yield - copystat(src, dst) - -def get_safe_path(dst): - if not os.path.exists(dst): - return dst - if not dst.endswith(APPENDIX): - dst += APPENDIX - if not os.path.exists(dst): - return dst - n = 0 - test_dst = dst + str(n) - while os.path.exists(test_dst): - n += 1 - test_dst = dst + str(n) - - return test_dst - -def copytree(src, dst, symlinks=False, ignore=None, overwrite=False): - """Recursively copy a directory tree using copy2(). - - The destination directory must not already exist. - If exception(s) occur, an Error is raised with a list of reasons. - - If the optional symlinks flag is true, symbolic links in the - source tree result in symbolic links in the destination tree; if - it is false, the contents of the files pointed to by symbolic - links are copied. - - The optional ignore argument is a callable. If given, it - is called with the `src` parameter, which is the directory - being visited by copytree(), and `names` which is the list of - `src` contents, as returned by os.listdir(): - - callable(src, names) -> ignored_names - - Since copytree() is called recursively, the callable will be - called once for each directory that is copied. It returns a - list of names relative to the `src` directory that should - not be copied. - - XXX Consider this example code rather than the ultimate tool. - - """ - names = os.listdir(src) - if ignore is not None: - ignored_names = ignore(src, names) - else: - ignored_names = set() - - errors = [] - try: - os.makedirs(dst) - except Exception as err: - if not overwrite: - dst = get_safe_path(dst) - os.makedirs(dst) - for name in names: - if name in ignored_names: - continue - srcname = os.path.join(src, name) - dstname = os.path.join(dst, name) - try: - if symlinks and os.path.islink(srcname): - linkto = os.readlink(srcname) - if os.path.lexists(dstname): - if not os.path.islink(dstname) \ - or os.readlink(dstname) != linkto: - os.unlink(dstname) - os.symlink(linkto, dstname) - elif os.path.isdir(srcname): - for _ in copytree(srcname, dstname, symlinks, - ignore, overwrite): - yield - else: - # Will raise a SpecialFileError for unsupported file types - for _ in copy2(srcname, dstname, - overwrite=overwrite, symlinks=symlinks): - yield - # catch the Error from the recursive copytree so that we can - # continue with other files - except Error as err: - errors.extend(err.args[0]) - except EnvironmentError as why: - errors.append((srcname, dstname, str(why))) - 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.extend((src, dst, str(why))) - if errors: - raise Error(errors) - -def rmtree(path, ignore_errors=False, onerror=None): - """Recursively delete a directory tree. - - If ignore_errors is set, errors are ignored; otherwise, if onerror - is set, it is called to handle the error with arguments (func, - path, exc_info) where func is os.listdir, os.remove, or os.rmdir; - path is the argument to that function that caused it to fail; and - exc_info is a tuple returned by sys.exc_info(). If ignore_errors - is false and onerror is None, an exception is raised. - - """ - if ignore_errors: - def onerror(*args): - pass - elif onerror is None: - def onerror(*args): - raise - try: - if os.path.islink(path): - # symlinks to directories are forbidden, see bug #1669 - raise OSError("Cannot call rmtree on a symbolic link") - except OSError: - onerror(os.path.islink, path, sys.exc_info()) - # can't continue even if onerror hook returns - return - names = [] - try: - names = os.listdir(path) - except os.error as err: - onerror(os.listdir, path, sys.exc_info()) - for name in names: - fullname = os.path.join(path, name) - try: - mode = os.lstat(fullname).st_mode - except os.error: - mode = 0 - if stat.S_ISDIR(mode): - rmtree(fullname, ignore_errors, onerror) - else: - try: - os.remove(fullname) - except os.error as err: - onerror(os.remove, fullname, sys.exc_info()) - try: - os.rmdir(path) - except os.error: - onerror(os.rmdir, path, sys.exc_info()) - - -def _basename(path): - # A basename() variant which first strips the trailing slash, if present. - # Thus we always get the last component of the path, even for directories. - return os.path.basename(path.rstrip(os.path.sep)) - -def move(src, dst, overwrite=False): - """Recursively move a file or directory to another location. This is - similar to the Unix "mv" command. - - If the destination is a directory or a symlink to a directory, the source - is moved inside the directory. The destination path must not already - exist. - - If the destination already exists but is not a directory, it may be - overwritten depending on os.rename() semantics. - - If the destination is on our current filesystem, then rename() is used. - Otherwise, src is copied to the destination and then removed. - A lot more could be done here... A look at a mv.c shows a lot of - the issues this implementation glosses over. - - """ - real_dst = os.path.join(dst, _basename(src)) - if not overwrite: - real_dst = get_safe_path(real_dst) - try: - os.rename(src, real_dst) - except OSError: - if os.path.isdir(src): - if _destinsrc(src, dst): - raise Error("Cannot move a directory '%s' into itself '%s'." % (src, dst)) - for _ in copytree(src, real_dst, symlinks=True, overwrite=overwrite): - yield - rmtree(src) - else: - for _ in copy2(src, real_dst, symlinks=True, overwrite=overwrite): - yield - os.unlink(src) - -def _destinsrc(src, dst): - src = abspath(src) - dst = abspath(dst) - if not src.endswith(os.path.sep): - src += os.path.sep - if not dst.endswith(os.path.sep): - dst += os.path.sep - return dst.startswith(src) - -# vi: expandtab sts=4 ts=4 sw=4 diff --git a/ranger/fsobject/directory.py b/ranger/fsobject/directory.py index e52b84d7..7578ad5f 100644 --- a/ranger/fsobject/directory.py +++ b/ranger/fsobject/directory.py @@ -21,6 +21,7 @@ from os.path import join, isdir, basename from collections import deque from time import time +from ranger.core.loader import Loadable from ranger.ext.mount_path import mount_path from ranger.fsobject import BAD_INFO, File, FileSystemObject from ranger.core.shared import SettingsAware @@ -57,7 +58,7 @@ def accept_file(fname, hidden_filter, name_filter): return False return True -class Directory(FileSystemObject, Accumulator, SettingsAware): +class Directory(FileSystemObject, Accumulator, Loadable, SettingsAware): is_directory = True enterable = False load_generator = None diff --git a/ranger/fsobject/file.py b/ranger/fsobject/file.py index 5e79c2d1..4de77d80 100644 --- a/ranger/fsobject/file.py +++ b/ranger/fsobject/file.py @@ -14,10 +14,12 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. import re -import zipfile from ranger.fsobject import FileSystemObject +from subprocess import Popen, PIPE +from ranger.core.runner import devnull +from ranger.core.loader import CommandLoader -N_FIRST_BYTES = 20 +N_FIRST_BYTES = 256 control_characters = set(chr(n) for n in set(range(0, 9)) | set(range(14, 32))) @@ -28,12 +30,10 @@ PREVIEW_BLACKLIST = re.compile(r""" # one character extensions: [oa] # media formats: - | avi | [mj]pe?g | mp\d | og[gmv] | wm[av] | mkv | flv - | png | bmp | vob | wav | mpc | flac | divx? | xcf | pdf + | avi | mpe?g | mp\d | og[gmv] | wm[av] | mkv | flv + | vob | wav | mpc | flac | divx? | xcf | pdf # binary files: | torrent | class | so | img | py[co] | dmg - # containers: - | iso | rar | 7z | tar | gz | bz2 | tgz ) # ignore filetype-independent suffixes: (\.part|\.bak|~)? @@ -54,6 +54,9 @@ PREVIEW_WHITELIST = re.compile(r""" class File(FileSystemObject): is_file = True + preview_data = None + preview_known = False + preview_loading = False @property def firstbytes(self): @@ -74,23 +77,25 @@ class File(FileSystemObject): return False def has_preview(self): + if self.fm.settings.preview_script: + return True if not self.fm.settings.preview_files: return False if self.is_socket or self.is_fifo or self.is_device: return False if not self.accessible: return False + if self.image or self.container: + return False if PREVIEW_WHITELIST.search(self.basename): return True if PREVIEW_BLACKLIST.search(self.basename): return False if self.path == '/dev/core' or self.path == '/proc/kcore': return False - if self.extension not in ('zip',) and self.is_binary(): + if self.is_binary(): return False return True - def get_preview_source(self): - if self.extension == 'zip': - return '\n'.join(zipfile.ZipFile(self.path).namelist()) - return open(self.path, 'r') + def get_preview_source(self, width, height): + return self.fm.get_preview(self.realpath, width, height) diff --git a/ranger/fsobject/fsobject.py b/ranger/fsobject/fsobject.py index fd886275..9d9f3db6 100644 --- a/ranger/fsobject/fsobject.py +++ b/ranger/fsobject/fsobject.py @@ -14,8 +14,8 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. CONTAINER_EXTENSIONS = ('7z', 'ace', 'ar', 'arc', 'bz', 'bz2', 'cab', 'cpio', - 'cpt', 'dgc', 'dmg', 'gz', 'iso', 'jar', 'msi', 'pkg', 'rar', 'shar', - 'tar', 'tbz', 'tgz', 'xar', 'xz', 'zip') + 'cpt', 'deb', 'dgc', 'dmg', 'gz', 'iso', 'jar', 'msi', 'pkg', 'rar', + 'shar', 'tar', 'tbz', 'tgz', 'xar', 'xz', 'zip') import re from os import access, listdir, lstat, readlink, stat diff --git a/ranger/gui/ansi.py b/ranger/gui/ansi.py new file mode 100644 index 00000000..a9b37665 --- /dev/null +++ b/ranger/gui/ansi.py @@ -0,0 +1,94 @@ +# Copyright (C) 2010 David Barnett <davidbarnett2@gmail.com> +# Copyright (C) 2010 Roman Zimbelmann <romanz@lavabit.com> +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +from ranger.gui import color +import re + +ansi_re = re.compile('(\033' + r'\[\d*(?:;\d+)*?[a-zA-Z])') +reset = '\033[0m' + +def split_ansi_from_text(ansi_text): + return ansi_re.split(ansi_text) + +def text_with_fg_bg_attr(ansi_text): + for chunk in split_ansi_from_text(ansi_text): + if chunk and chunk[0] == '\033': + if chunk[-1] != 'm': + continue + match = re.match(r'^.\[(.*).$', chunk) + attr_args = match.group(1) + fg, bg, attr = -1, -1, 0 + + # Convert arguments to attributes/colors + for arg in attr_args.split(';'): + try: + n = int(arg) + except: + if arg == '': + n = 0 + else: + continue + if n == 0: + fg, bg, attr = -1, -1, 0 + elif n == 1: + attr |= color.bold + elif n == 4: + attr |= color.underline + elif n == 7: + attr |= color.reverse + elif n == 8: + attr |= color.invisible + elif n >= 30 and n <= 37: + fg = n - 30 + elif n == 39: + fg = -1 + elif n >= 40 and n <= 47: + bg = n - 40 + elif n == 49: + bg = -1 + yield (fg, bg, attr) + else: + yield chunk + +def char_len(ansi_text): + return len(ansi_re.sub('', ansi_text)) + +def char_slice(ansi_text, start, end): + slice_chunks = [] + # skip to start + last_color = None + skip_len_left = start + len_left = end - start + for chunk in split_ansi_from_text(ansi_text): + m = ansi_re.match(chunk) + if m: + if chunk[-1] == 'm': + last_color = chunk + else: + if skip_len_left > len(chunk): + skip_len_left -= len(chunk) + else: # finished skipping to start + if skip_len_left > 0: + chunk = chunk[skip_len_left:] + chunk_left = chunk[:len_left] + if len(chunk_left): + if last_color is not None: + slice_chunks.append(last_color) + slice_chunks.append(chunk_left) + len_left -= len(chunk_left) + if len_left == 0: + break + return ''.join(slice_chunks) diff --git a/ranger/gui/curses_shortcuts.py b/ranger/gui/curses_shortcuts.py index 4ed348fd..bae03adc 100644 --- a/ranger/gui/curses_shortcuts.py +++ b/ranger/gui/curses_shortcuts.py @@ -1,4 +1,5 @@ # Copyright (C) 2009, 2010 Roman Zimbelmann <romanz@lavabit.com> +# Copyright (C) 2010 David Barnett <davidbarnett2@gmail.com> # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -13,9 +14,11 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. +import curses import _curses from ranger.ext.iter_tools import flatten +from ranger.gui.color import get_color from ranger.core.shared import SettingsAware def ascii_only(string): @@ -95,6 +98,12 @@ class CursesShortcuts(SettingsAware): except _curses.error: pass + def set_fg_bg_attr(self, fg, bg, attr): + try: + self.win.attrset(curses.color_pair(get_color(fg, bg)) | attr) + except _curses.error: + pass + def color_reset(self): """Change the colors to the default colors""" CursesShortcuts.color(self, 'reset') diff --git a/ranger/gui/widgets/browsercolumn.py b/ranger/gui/widgets/browsercolumn.py index dacc3920..eb898e2a 100644 --- a/ranger/gui/widgets/browsercolumn.py +++ b/ranger/gui/widgets/browsercolumn.py @@ -156,8 +156,9 @@ class BrowserColumn(Pager): return try: - f = self.target.get_preview_source() + f = self.target.get_preview_source(self.wid, self.hei) except: + raise # XXX Pager.close(self) else: if f is None: diff --git a/ranger/gui/widgets/pager.py b/ranger/gui/widgets/pager.py index c0bc98b4..798c4697 100644 --- a/ranger/gui/widgets/pager.py +++ b/ranger/gui/widgets/pager.py @@ -1,4 +1,5 @@ # Copyright (C) 2009, 2010 Roman Zimbelmann <romanz@lavabit.com> +# Copyright (C) 2010 David Barnett <davidbarnett2@gmail.com> # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -18,6 +19,7 @@ The pager displays text and allows you to scroll inside it. """ import re from . import Widget +from ranger.gui import ansi from ranger.ext.direction import Direction from ranger.container.keymap import CommandArgs @@ -33,6 +35,7 @@ class Pager(Widget): old_source = None old_scroll_begin = 0 old_startx = 0 + max_width = None def __init__(self, win, embedded=False): Widget.__init__(self, win) self.embedded = embedded @@ -106,6 +109,13 @@ class Pager(Widget): if TITLE_REGEXP.match(line): self.color_at(i, 0, -1, 'title', *baseclr) + elif self.markup == 'ansi': + self.win.move(i, 0) + for chunk in ansi.text_with_fg_bg_attr(line): + if isinstance(chunk, tuple): + self.set_fg_bg_attr(*chunk) + else: + self.addstr(chunk) def move(self, narg=None, **kw): direction = Direction(kw) @@ -113,7 +123,7 @@ class Pager(Widget): self.startx = direction.move( direction=direction.right(), override=narg, - maximum=self._get_max_width(), + maximum=self.max_width, current=self.startx, pagesize=self.wid, offset=-self.wid + 1) @@ -158,12 +168,14 @@ class Pager(Widget): if isinstance(source, str): self.source_is_stream = False - self.lines = source.split('\n') + self.markup = 'ansi' + self.lines = source.splitlines() elif hasattr(source, '__getitem__'): self.source_is_stream = False self.lines = source elif hasattr(source, 'readline'): self.source_is_stream = True + self.markup = 'ansi' self.lines = [] else: self.source = None @@ -191,6 +203,8 @@ class Pager(Widget): if attempt_to_read and self.source_is_stream: try: for l in self.source: + if len(l) > self.max_width: + self.max_width = len(l) self.lines.append(l) if len(self.lines) > n: break @@ -206,11 +220,12 @@ class Pager(Widget): while True: try: line = self._get_line(i).expandtabs(4) - line = line[startx:self.wid + startx].rstrip() - yield line + if self.markup is 'ansi': + line = ansi.char_slice(line, startx, self.wid + startx) \ + + ansi.reset + else: + line = line[startx:self.wid + startx] + yield line.rstrip() except IndexError: raise StopIteration i += 1 - - def _get_max_width(self): - return max(len(line) for line in self.lines) diff --git a/test/tc_ansi.py b/test/tc_ansi.py new file mode 100644 index 00000000..0a6ad8b1 --- /dev/null +++ b/test/tc_ansi.py @@ -0,0 +1,40 @@ +# Copyright (C) 2010 David Barnett <davidbarnett2@gmail.com> +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +if __name__ == '__main__': from __init__ import init; init() + +import unittest +from ranger.gui import ansi + +class TestDisplayable(unittest.TestCase): + def test_char_len(self): + ansi_string = "[0;30;40mX[0m" + self.assertEqual(ansi.char_len(ansi_string), 1) + + def test_char_len2(self): + ansi_string = "[0;30;40mXY[0m" + self.assertEqual(ansi.char_len(ansi_string), 2) + + def test_char_len3(self): + ansi_string = "[0;30;40mX[0;31;41mY" + self.assertEqual(ansi.char_len(ansi_string), 2) + + def test_char_slice(self): + ansi_string = "[0;30;40mX[0;31;41mY[0m" + expected = "[0;30;40mX" + self.assertEqual(ansi.char_slice(ansi_string, 0, 1), expected) + +if __name__ == '__main__': + unittest.main() |