# This file is part of ranger, the console file manager.
# License: GNU GPL version 3, see the file "AUTHORS" for details.
"""The BrowserColumn widget displays the contents of a directory or file."""
from __future__ import (absolute_import, division, print_function)
import curses
import stat
from time import time
from os.path import splitext
from ranger.ext.widestring import WideString
from ranger.core import linemode
from . import Widget
from .pager import Pager
def hook_before_drawing(fsobject, color_list):
return fsobject, color_list
class BrowserColumn(Pager): # pylint: disable=too-many-instance-attributes
main_column = False
display_infostring = False
display_vcsstate = True
scroll_begin = 0
target = None
last_redraw_time = -1
old_dir = None
old_thisfile = None
def __init__(self, win, level, tab=None):
"""Initializes a Browser Column Widget
win = the curses window object of the BrowserView
level = what to display?
level >0 => previews
level 0 => current file/directory
level <0 => parent directories
"""
self.need_redraw = False
self.image = None
self.need_clear_image = True
Pager.__init__(self, win)
Widget.__init__(self, win) # pylint: disable=non-parent-init-called
self.level = level
self.tab = tab
self.original_level = level
self.settings.signal_bind('setopt.display_size_in_main_column',
self.request_redraw, weak=True)
def request_redraw(self):
self.need_redraw = True
def click(self, event): # pylint: disable=too-many-branches
"""Handle a MouseEvent"""
direction = event.mouse_wheel_direction()
if not (event.pressed(1) or event.pressed(3) or direction):
return False
if self.target is None:
pass
elif self.target.is_directory:
if self.target.accessible and self.target.content_loaded:
index = self.scroll_begin + event.y - self.y
if direction:
if self.level == -1:
self.fm.move_parent(direction)
else:
return False
elif event.pressed(1):
if not self.main_column:
self.fm.enter_dir(self.target.path)
if index < len(self.target):
self.fm.move(to=index)
elif event.pressed(3):
try:
clicked_file = self.target.files[index]
except IndexError:
pass
else:
if clicked_file.is_directory:
self.fm.enter_dir(clicked_file.path, remember=True)
elif self.level == 0:
self.fm.thisdir.move_to_obj(clicked_file)
self.fm.execute_file(clicked_file)
elif self.target.is_file:
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)
return True
def execute_curses_batch(self, line, commands):
"""Executes a list of "commands" which can be easily cached.
"commands" is a list of lists. Each element contains
a text and an attribute. First, the attribute will be
set with attrset, then the text is printed.
Example:
execute_curses_batch(0, [["hello ", 0], ["world", curses.A_BOLD]])
"""
try:
self.win.move(line, 0)
except curses.error:
return
for entry in commands:
text, attr = entry
self.addstr(text, attr)
def has_preview(self):
if self.target is None:
return False
if self.target.is_file:
if not self.target.has_preview():
return False
if self.target.is_directory:
if self.level > 0 and not self.settings.preview_directories:
return False
return True
def level_shift(self, amount):
self.level = self.original_level + amount
def level_restore(self):
self.level = self.original_level
def poke(self):
Widget.poke(self)
if self.tab is None:
tab = self.fm.thistab
else:
tab = self.tab
self.target = tab.at_level(self.level)
def draw(self):
"""Call either _draw_file() or _draw_directory()"""
target = self.target
if target != self.old_dir:
self.need_redraw = True
self.old_dir = target
self.scroll_extra = 0 # reset scroll start
if target:
target.use()
if target.is_directory and (self.level <= 0 or self.settings.preview_directories):
if self.old_thisfile != target.pointed_obj:
self.old_thisfile = target.pointed_obj
self.need_redraw = True
self.need_redraw |= target.load_content_if_outdated()
self.need_redraw |= target.sort_if_outdated()
self.need_redraw |= self.last_redraw_time < target.last_update_time
if target.pointed_obj:
self.need_redraw |= target.pointed_obj.load_if_outdated()
self.need_redraw |= self.last_redraw_time < target.pointed_obj.last_load_time
else:
self.need_redraw |= target.load_if_outdated()
self.need_redraw |= self.last_redraw_time < target.last_load_time
if self.need_redraw:
self.win.erase()
if target is None:
pass
elif target.is_file:
Pager.open(self)
self._draw_file()
elif target.is_directory:
self._draw_directory()
Widget.draw(self)
self.need_redraw = False
self.last_redraw_time = time()
def _draw_file(self):
"""Draw a preview of the file, if the settings allow it"""
self.win.move(0, 0)
if self.target is None or not self.target.has_preview():
Pager.close(self)
return
if not self.target.accessible:
self.addnstr("not accessible", self.wid)
Pager.close(self)
return
path = self.target.get_preview_source(self.wid, self.hei)
if path is None:
Pager.close(self)
else:
if self.target.is_image_preview():
self.set_image(path)
else:
self.set_source(path)
Pager.draw(self)
def _format_line_number(self, linum_format, i, selected_i):
line_number = i
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:
line_number = selected_i + 1
else:
line_number = selected_i
elif self.settings.one_indexed:
line_number += 1
return linum_format.format(line_number)
def _draw_directory( # pylint: disable=too-many-locals,too-many-branches,too-many-statements
self):
"""Draw the contents of a directory"""
if self.image:
self.image = None
self.need_clear_image = True
Pager.clear_image(self)
if self.level > 0 and not self.settings.preview_directories:
return
base_color = ['in_browser']
if self.fm.ui.viewmode == 'multipane' and self.tab is not None:
active_pane = self.tab == self.fm.thistab
if active_pane:
base_color.append('active_pane')
else:
base_color.append('inactive_pane')
else:
active_pane = False
self.win.move(0, 0)
if not self.target.content_loaded:
self.color(tuple(base_color))
self.addnstr("...", self.wid)
self.color_reset()
return
if self.main_column:
base_color.append('main_column')
if not self.target.accessible:
self.color(tuple(base_color + ['error']))
self.addnstr("not accessible", self.wid)
self.color_reset()
return
if self.target.empty():
self.color(tuple(base_color + ['empty']))
self.addnstr("empty", self.wid)
self.color_reset()
return
self._set_scroll_begin()
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.
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) + "}"
for line in range(self.hei):
i = line + self.scroll_begin
try:
drawn = self.target.files[i]
except IndexError:
break
tagged = self.fm.tags and drawn.realpath in self.fm.tags
if tagged:
tagged_marker = self.fm.tags.marker(drawn.realpath)
else:
tagged_marker = " "
# Extract linemode-related information from the drawn object
metadata = None
current_linemode = drawn.linemode_dict[drawn.linemode]
if current_linemode.uses_metadata:
metadata = self.fm.metadata.get_metadata(drawn.path)
if not all(getattr(metadata, tag)
for tag in current_linemode.required_metadata):
current_linemode = drawn.linemode_dict[linemode.DEFAULT_LINEMODE]
metakey = hash(repr(sorted(metadata.items()))) if metadata else 0
key = (self.wid, selected_i == i, drawn.marked, self.main_column,
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.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.lower() != 'false'
):
line_number_text = self._format_line_number(linum_format,
i,
selected_i)
drawn.display_data[key][0][0] = line_number_text
self.execute_curses_batch(line, drawn.display_data[key])
self.color_reset()
continue
text = current_linemode.filetitle(drawn, metadata)
if drawn.marked and (self.main_column
or self.settings.display_tags_in_all_columns):
text = " " + text
# Computing predisplay data. predisplay contains a list of lists
# [string, colorlst] where string is a piece of string to display,
# and colorlst a list of contexts that we later pass to the
# colorscheme, to compute the curses attribute.
predisplay_left = []
predisplay_right = []
space = self.wid
# line number field
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, ['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)
tagmarklen = self._total_len(tagmark)
if space - tagmarklen > 2:
predisplay_left += tagmark
space -= tagmarklen
# vcs data
vcsstring = self._draw_vcsstring_display(drawn)
vcsstringlen = self._total_len(vcsstring)
if space - vcsstringlen > 2:
predisplay_right += vcsstring
space -= vcsstringlen
# info string
infostring = []
infostringlen = 0
try:
infostringdata = current_linemode.infostring(drawn, metadata)
if 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:
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)
predisplay_left += textstring
space -= textstringlen
assert space >= 0, "Error: there is not enough space to write the text. " \
"I have computed spaces wrong."
if space > 0:
predisplay_left.append([' ' * space, []])
# Computing display data. Now we compute the display_data list
# ready to display in curses. It is a list of lists [string, attr]
this_color = base_color + list(drawn.mimetype_tuple) + \
self._draw_directory_color(i, drawn, copied)
display_data = []
drawn.display_data[key] = display_data
drawn, this_color = hook_before_drawing(drawn, this_color)
predisplay = predisplay_left + predisplay_right
for txt, color in predisplay:
attr = self.settings.colorscheme.get_attr(*(this_color + color))
display_data.append([txt, attr])
self.execute_curses_batch(line, display_data)
self.color_reset()
def _get_index_of_selected_file(self):
if self.fm.ui.viewmode == 'multipane' and self.tab != self.fm.thistab:
return self.tab.pointer
return self.target.pointer
@staticmethod
def _total_len(predisplay):
return sum([len(WideString(s)) for s, _ in predisplay])
def _draw_text_display(self, text, space):
bidi_text = self.bidi_transpose(text)
wtext = WideString(bidi_text)
wext = WideString(splitext(bidi_text)[1])
wellip = WideString(self.ellipsis[self.settings.unicode_ellipsis])
if len(wtext) > space:
wtext = wtext[:max(1, space - len(wext) - len(wellip))] + wellip + wext
# Truncate again if still too long.
if len(wtext) > space:
wtext = wtext[:max(0, space - len(wellip))] + wellip
return [[str(wtext), []]]
def _draw_tagged_display(self, tagged, tagged_marker):
tagged_display = []
if (self.main_column or self.settings.display_tags_in_all_columns) \
and self.wid > 2:
if tagged:
tagged_display.append([tagged_marker, ['tag_marker']])
else:
tagged_display.append([" ", ['tag_marker']])
return tagged_display
def _draw_infostring_display(self, drawn, space):
infostring_display = []
if self.display_infostring and drawn.infostring \
and self.settings.display_size_in_main_column:
infostring = str(drawn.infostring)
if len(infostring) <= space:
infostring_display.append([infostring, ['infostring']])
return infostring_display
def _draw_vcsstring_display(self, drawn):
vcsstring_display = []
if (self.target.vcs and self.target.vcs.track) \
or (drawn.is_directory and drawn.vcs and drawn.vcs.track):
if drawn.vcsremotestatus:
vcsstr, vcscol = self.vcsremotestatus_symb[drawn.vcsremotestatus]
vcsstring_display.append([vcsstr, ['vcsremote'] + vcscol])
elif self.target.has_vcschild:
vcsstring_display.append([' ', []])
if drawn.vcsstatus:
vcsstr, vcscol = self.vcsstatus_symb[drawn.vcsstatus]
vcsstring_display.append([vcsstr, ['vcsfile'] + vcscol])
elif self.target.has_vcschild:
vcsstring_display.append([' ', []])
elif self.target.has_vcschild:
vcsstring_display.append([' ', []])
return vcsstring_display
def _draw_directory_color(self, i, drawn, copied):
this_color = []
if i == self._get_index_of_selected_file():
this_color.append('selected')
if drawn.marked:
this_color.append('marked')
if self.fm.tags and drawn.realpath in self.fm.tags:
this_color.append('tagged')
if drawn.is_directory:
this_color.append('directory')
else:
this_color.append('file')
if drawn.stat:
mode = drawn.stat.st_mode
if mode & stat.S_IXUSR:
this_color.append('executable')
if stat.S_ISFIFO(mode):
this_color.append('fifo')
if stat.S_ISSOCK(mode):
this_color.append('socket')
if drawn.is_device:
this_color.append('device')
if drawn.path in copied:
this_color.append('cut' if self.fm.do_cut else 'copied')
if drawn.is_link:
this_color.append('link')
this_color.append(drawn.exists and 'good' or 'bad')
return this_color
def _get_scroll_begin(self): # pylint: disable=too-many-return-statements
"""Determines scroll_begin (the position of the first displayed file)"""
offset = self.settings.scroll_offset
dirsize = len(self.target)
winsize = self.hei
halfwinsize = winsize // 2
index = self._get_index_of_selected_file() or 0
original = self.target.scroll_begin
projected = index - original
upper_limit = winsize - 1 - offset
lower_limit = offset
if original < 0:
return 0
if dirsize < winsize:
return 0
if halfwinsize < offset:
return min(dirsize - winsize, max(0, index - halfwinsize))
if original > dirsize - winsize:
self.target.scroll_begin = dirsize - winsize
return self._get_scroll_begin()
if lower_limit < projected < upper_limit:
return original
if projected > upper_limit:
return min(dirsize - winsize, original + (projected - upper_limit))
if projected < upper_limit:
return max(0, original - (lower_limit - projected))
return original
def _set_scroll_begin(self):
"""Updates the scroll_begin value"""
self.scroll_begin = self._get_scroll_begin()
self.target.scroll_begin = self.scroll_begin
def scroll(self, n):
"""scroll down by n lines"""
self.need_redraw = True
self.target.move(down=n)
self.target.scroll_begin += 3 * n
def __str__(self):
return self.__class__.__name__ + ' at level ' + str(self.level)