1# This file is part of ranger, the console file manager.
2# License: GNU GPL version 3, see the file "AUTHORS" for details.
3
4"""The pager displays text and allows you to scroll inside it."""
5
6from __future__ import (absolute_import, division, print_function)
7
8import curses
9import logging
10
11from ranger.gui import ansi
12from ranger.ext.direction import Direction
13from ranger.ext.img_display import ImgDisplayUnsupportedException
14
15from . import Widget
16
17
18LOG = logging.getLogger(__name__)
19
20
21# TODO: Scrolling in embedded pager
22class Pager(Widget):  # pylint: disable=too-many-instance-attributes
23    source = None
24    source_is_stream = False
25
26    old_source = None
27    old_scroll_begin = 0
28    old_startx = 0
29    need_clear_image = False
30    need_redraw_image = False
31    max_width = None
32
33    def __init__(self, win, embedded=False):
34        Widget.__init__(self, win)
35        self.embedded = embedded
36        self.scroll_begin = 0
37        self.scroll_extra = 0
38        self.startx = 0
39        self.markup = None
40        self.lines = []
41        self.image = None
42        self.image_drawn = False
43
44    def _close_source(self):
45        if self.source and self.source_is_stream:
46            try:
47                self.source.close()
48            except OSError as ex:
49                LOG.error('Unable to close pager source')
50                LOG.exception(ex)
51
52    def open(self):
53        self.scroll_begin = 0
54        self.markup = None
55        self.max_width = 0
56        self.startx = 0
57        self.need_redraw = True
58
59    def clear_image(self, force=False):
60        if (force or self.need_clear_image) and self.image_drawn:
61            self.fm.image_displayer.clear(self.x, self.y, self.wid, self.hei)
62            self.need_clear_image = False
63            self.image_drawn = False
64
65    def close(self):
66        if self.image:
67            self.need_clear_image = True
68            self.clear_image()
69        self._close_source()
70
71    def destroy(self):
72        self.clear_image(force=True)
73        Widget.destroy(self)
74
75    def finalize(self):
76        self.fm.ui.win.move(self.y, self.x)
77
78    def scrollbit(self, lines):
79        target_scroll = self.scroll_extra + lines
80        max_scroll = len(self.lines) - self.hei
81        self.scroll_extra = max(0, min(target_scroll, max_scroll))
82        self.need_redraw = True
83
84    def draw(self):
85        if self.need_clear_image:
86            self.need_redraw = True
87
88        if self.old_source != self.source:
89            self.old_source = self.source
90            self.need_redraw = True
91
92        if self.old_scroll_begin != self.scroll_begin or \
93                self.old_startx != self.startx:
94            self.old_startx = self.startx
95            self.old_scroll_begin = self.scroll_begin
96            self.need_redraw = True
97
98        if self.need_redraw:
99            self.win.erase()
100            self.need_redraw_image = True
101            self.clear_image()
102
103            if not self.image:
104                scroll_pos = self.scroll_begin + self.scroll_extra
105                line_gen = self._generate_lines(
106                    starty=scroll_pos, startx=self.startx)
107
108                for line, i in zip(line_gen, range(self.hei)):
109                    self._draw_line(i, line)
110
111            self.need_redraw = False
112
113    def draw_image(self):
114        if self.image and self.need_redraw_image:
115            self.source = None
116            self.need_redraw_image = False
117            try:
118                self.fm.image_displayer.draw(self.image, self.x, self.y,
119                                             self.wid, self.hei)
120            except ImgDisplayUnsupportedException as ex:
121                self.fm.settings.preview_images = False
122                self.fm.notify(ex, bad=True)
123            except Exception as ex:  # pylint: disable=broad-except
124                self.fm.notify(ex, bad=True)
125            else:
126                self.image_drawn = True
127
128    def _draw_line(self, i, line):
129        if self.markup is None:
130            self.addstr(i, 0, line)
131        elif self.markup == 'ansi':
132            try:
133                self.win.move(i, 0)
134            except curses.error:
135                pass
136            else:
137                for chunk in ansi.text_with_fg_bg_attr(line):
138                    if isinstance(chunk, tuple):
139                        self.set_fg_bg_attr(*chunk)
140                    else:
141                        self.addstr(chunk)
142
143    def move(self, narg=None, **kw):
144        direction = Direction(kw)
145        if direction.horizontal():
146            self.startx = direction.move(
147                direction=direction.right(),
148                override=narg,
149                maximum=self.max_width,
150                current=self.startx,
151                pagesize=self.wid,
152                offset=-self.wid + 1)
153        if direction.vertical():
154            movement = dict(
155                direction=direction.down(),
156                override=narg,
157                current=self.scroll_begin,
158                pagesize=self.hei,
159                offset=-self.hei + 1)
160            if self.source_is_stream:
161                # For streams, we first pretend that the content ends much later,
162                # in case there are still unread lines.
163                desired_position = direction.move(
164                    maximum=len(self.lines) + 9999,
165                    **movement)
166                # Then, read the new lines as needed to produce a more accurate
167                # maximum for the movement:
168                self._get_line(desired_position + self.hei)
169            self.scroll_begin = direction.move(
170                maximum=len(self.lines),
171                **movement)
172
173    def press(self, key):
174        self.fm.ui.keymaps.use_keymap('pager')
175        self.fm.ui.press(key)
176
177    def set_image(self, image):
178        if self.image:
179            self.need_clear_image = True
180        self.image = image
181        self._close_source()
182        self.source = None
183        self.source_is_stream = False
184
185    def set_source(self, source, strip=False):
186        if self.image:
187            self.image = None
188            self.need_clear_image = True
189        self._close_source()
190
191        self.max_width = 0
192        if isinstance(source, str):
193            self.source_is_stream = False
194            self.lines = source.splitlines()
195            if self.lines:
196                self.max_width = max(len(line) for line in self.lines)
197        elif hasattr(source, '__getitem__'):
198            self.source_is_stream = False
199            self.lines = source
200            if self.lines:
201                self.max_width = max(len(line) for line in source)
202        elif hasattr(source, 'readline'):
203            self.source_is_stream = True
204            self.lines = []
205        else:
206            self.source = None
207            self.source_is_stream = False
208            return False
209        self.markup = 'ansi'
210
211        if not self.source_is_stream and strip:
212            self.lines = [line.strip() for line in self.lines]
213
214        self.source = source
215        return True
216
217    def click(self, event):
218        n = 1 if event.ctrl() else 3
219        direction = event.mouse_wheel_direction()
220        if direction:
221            self.move(down=direction * n)
222        return True
223
224    def _get_line(self, n, attempt_to_read=True):
225        assert isinstance(n, int), n
226        try:
227            return self.lines[n]
228        except (KeyError, IndexError):
229            if attempt_to_read and self.source_is_stream:
230                try:
231                    for line in self.source:
232                        if len(line) > self.max_width:
233                            self.max_width = len(line)
234                        self.lines.append(line)
235                        if len(self.lines) > n:
236                            break
237                except (UnicodeError, IOError):
238                    pass
239                return self._get_line(n, attempt_to_read=False)
240            return ""
241
242    def _generate_lines(self, starty, startx):
243        i = starty
244        if not self.source:
245            return
246        while True:
247            try:
248                line = self._get_line(i).expandtabs(4)
249                for part in ((0,) if not
250                             self.fm.settings.wrap_plaintext_previews else
251                             range(max(1, ((len(line) - 1) // self.wid) + 1))):
252                    shift = part * self.wid
253                    if self.markup == 'ansi':
254                        line_bit = (ansi.char_slice(line, startx + shift,
255                                                    self.wid + shift)
256                                    + ansi.reset)
257                    else:
258                        line_bit = line[startx + shift:self.wid + startx
259                                        + shift]
260                    yield line_bit.rstrip().replace('\r\n', '\n')
261            except IndexError:
262                return
263            i += 1
264