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.py9
-rw-r--r--ranger/core/fm.py6
-rw-r--r--ranger/core/main.py1
-rw-r--r--ranger/core/shared.py6
-rwxr-xr-xranger/data/scope.sh49
-rw-r--r--ranger/defaults/options.py7
-rw-r--r--ranger/fsobject/file.py27
-rw-r--r--ranger/gui/ansi.py94
-rw-r--r--ranger/gui/curses_shortcuts.py9
-rw-r--r--ranger/gui/widgets/browsercolumn.py2
-rw-r--r--ranger/gui/widgets/pager.py28
-rw-r--r--test/tc_ansi.py40
14 files changed, 261 insertions, 24 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..4b6a4ff4 100644
--- a/ranger/core/actions.py
+++ b/ranger/core/actions.py
@@ -569,13 +569,8 @@ class Actions(FileManagerAware, EnvironmentAware, SettingsAware):
 		if not hasattr(self.ui, 'open_embedded_pager'):
 			return
 
-		try:
-			f = open(self.env.cf.path, 'r')
-		except:
-			pass
-		else:
-			pager = self.ui.open_embedded_pager()
-			pager.set_source(f)
+		pager = self.ui.open_embedded_pager()
+		pager.set_source(self.env.cf.get_preview_source(pager))
 
 	# --------------------------
 	# -- Tabs
diff --git a/ranger/core/fm.py b/ranger/core/fm.py
index 5e01f96f..e468e58c 100644
--- a/ranger/core/fm.py
+++ b/ranger/core/fm.py
@@ -106,6 +106,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/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..175395ec 100644
--- a/ranger/core/shared.py
+++ b/ranger/core/shared.py
@@ -54,6 +54,12 @@ class SettingsAware(Awareness):
 		settings.signal_bind('setopt.colorscheme',
 				_colorscheme_name_to_class, priority=1)
 
+		def postprocess_paths(signal):
+			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..80fd2ea7
--- /dev/null
+++ b/ranger/data/scope.sh
@@ -0,0 +1,49 @@
+#!/bin/bash
+
+# This script is called whenever you preview a file.
+# Its output is used as the preview.  ANSI color codes are supported.
+
+# NOTE: This is considered to be a configuration file.  If you upgrade
+# ranger, it will be left untouched. (You must update it yourself)
+
+# Meanings of arguments:
+# name | meaning
+# -----+--------------------------------------------------------
+# $1   | Full filename of the selected file
+# $2   | Width of the preview pane (number of fitting characters)
+# $3   | Height of the preview pane (number of fitting characters)
+
+# 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
+
+mimetype=$(file --mime-type -Lb "$1")
+extension=$(echo "$1" | grep '\.' | grep -o '[^.]\+$')
+
+case "$extension" in
+	# Archive extensions:
+	tar|gz|tgz|bz|tbz|bz2|tbz2|Z|tZ|lzo|tzo|lz|tlz|xz|txz|7z|t7z|\
+	zip|jar|war|rar|lha|lzh|alz|ace|a|arj|arc|rpm|cab|lzma|rz|cpio)
+		atool -l "$1" || exit 1
+		exit 0;;
+	# HTML Pages:
+	htm|html|xhtml)
+		lynx -dump "$1" || elinks -dump "$1" || exit 1
+		exit 0;;
+esac
+
+case "$mimetype" in
+	# Syntax highlight for text files:
+	text/* | */xml)
+		highlight --ansi "$1" || cat "$1" || exit 1
+		exit 0;;
+	# Ascii-previews of images:
+	image/*)
+		img2txt -W "$2" "$1" || exit 1
+		exit 0;;
+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/fsobject/file.py b/ranger/fsobject/file.py
index 5e79c2d1..57d82b31 100644
--- a/ranger/fsobject/file.py
+++ b/ranger/fsobject/file.py
@@ -14,8 +14,9 @@
 # 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
 
 N_FIRST_BYTES = 20
 control_characters = set(chr(n) for n in
@@ -28,12 +29,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|~)?
@@ -80,17 +79,27 @@ class File(FileSystemObject):
 			return False
 		if not self.accessible:
 			return False
+		if self.image or self.container:
+			return True
 		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())
+	def get_preview_source(self, widget):
+		if self.fm.settings.preview_script:
+			try:
+				p = Popen([self.fm.settings.preview_script, self.path,
+						str(widget.wid), str(widget.hei)],
+						stdout=PIPE, stderr=devnull)
+				if p.poll():  # nonzero exit code
+					return None
+				return p.stdout
+			except:
+				pass
 		return open(self.path, 'r')
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..63323f65 100644
--- a/ranger/gui/widgets/browsercolumn.py
+++ b/ranger/gui/widgets/browsercolumn.py
@@ -156,7 +156,7 @@ class BrowserColumn(Pager):
 			return
 
 		try:
-			f = self.target.get_preview_source()
+			f = self.target.get_preview_source(self)
 		except:
 			Pager.close(self)
 		else:
diff --git a/ranger/gui/widgets/pager.py b/ranger/gui/widgets/pager.py
index c0bc98b4..5e761a0f 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,13 @@ class Pager(Widget):
 
 		if isinstance(source, str):
 			self.source_is_stream = False
-			self.lines = source.split('\n')
+			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 +202,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 +219,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()