summary refs log tree commit diff stats
diff options
context:
space:
mode:
-rw-r--r--README6
-rw-r--r--ranger/container/settingobject.py1
-rw-r--r--ranger/core/actions.py114
-rw-r--r--ranger/core/fm.py22
-rw-r--r--ranger/core/loader.py103
-rw-r--r--ranger/core/main.py1
-rw-r--r--ranger/core/shared.py7
-rwxr-xr-xranger/data/scope.sh59
-rw-r--r--ranger/defaults/options.py7
-rw-r--r--ranger/ext/shutil_generatorized.py302
-rw-r--r--ranger/fsobject/directory.py3
-rw-r--r--ranger/fsobject/file.py27
-rw-r--r--ranger/fsobject/fsobject.py4
-rw-r--r--ranger/gui/ansi.py94
-rw-r--r--ranger/gui/curses_shortcuts.py9
-rw-r--r--ranger/gui/widgets/browsercolumn.py3
-rw-r--r--ranger/gui/widgets/pager.py29
-rw-r--r--test/tc_ansi.py40
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 = "X"
+		self.assertEqual(ansi.char_len(ansi_string), 1)
+
+	def test_char_len2(self):
+		ansi_string = "XY"
+		self.assertEqual(ansi.char_len(ansi_string), 2)
+
+	def test_char_len3(self):
+		ansi_string = "XY"
+		self.assertEqual(ansi.char_len(ansi_string), 2)
+
+	def test_char_slice(self):
+		ansi_string = "XY"
+		expected = "X"
+		self.assertEqual(ansi.char_slice(ansi_string, 0, 1), expected)
+
+if __name__ == '__main__':
+	unittest.main()