1# This file is part of ranger, the console file manager.
2# License: GNU GPL version 3, see the file "AUTHORS" for details.
3
4from __future__ import (absolute_import, division, print_function)
5
6import os
7import sys
8import threading
9import curses
10from subprocess import CalledProcessError
11
12from ranger.ext.get_executables import get_executables
13from ranger.ext.keybinding_parser import KeyBuffer, KeyMaps, ALT_KEY
14from ranger.ext.lazy_property import lazy_property
15from ranger.ext.signals import Signal
16from ranger.ext.spawn import check_output
17
18from .displayable import DisplayableContainer
19from .mouse_event import MouseEvent
20
21
22MOUSEMASK = curses.ALL_MOUSE_EVENTS | curses.REPORT_MOUSE_POSITION
23
24# This escape is not available with a capname from terminfo unlike
25# tsl (to_status_line), so it's hardcoded here. It's used just like tsl,
26# but it sets the icon title (WM_ICON_NAME) instead of the window title
27# (WM_NAME).
28ESCAPE_ICON_TITLE = '\033]1;'
29
30_ASCII = ''.join(chr(c) for c in range(32, 127))
31
32
33def ascii_only(string):
34    return ''.join(c if c in _ASCII else '?' for c in string)
35
36
37def _setup_mouse(signal):
38    if signal['value']:
39        curses.mousemask(MOUSEMASK)
40        curses.mouseinterval(0)
41
42        # This line solves this problem:
43        # If a mouse click triggers an action that disables curses and
44        # starts curses again, (e.g. running a ## file by clicking on its
45        # preview) and the next key is another mouse click, the bstate of this
46        # mouse event will be invalid.  (atm, invalid bstates are recognized
47        # as scroll-down, so this avoids an errorneous scroll-down action)
48        curses.ungetmouse(0, 0, 0, 0, 0)
49    else:
50        curses.mousemask(0)
51
52
53def _in_tmux():
54    return ('TMUX' in os.environ
55            and 'tmux' in get_executables())
56
57
58def _in_screen():
59    return ('screen' in os.environ['TERM']
60            and 'screen' in get_executables())
61
62
63class UI(  # pylint: disable=too-many-instance-attributes,too-many-public-methods
64        DisplayableContainer):
65    ALLOWED_VIEWMODES = 'miller', 'multipane'
66
67    is_set_up = False
68    load_mode = False
69    is_on = False
70    termsize = None
71
72    def __init__(self, env=None, fm=None):  # pylint: disable=super-init-not-called
73        self.keybuffer = KeyBuffer()
74        self.keymaps = KeyMaps(self.keybuffer)
75        self.redrawlock = threading.Event()
76        self.redrawlock.set()
77
78        self.titlebar = None
79        self._viewmode = None
80        self.taskview = None
81        self.status = None
82        self.console = None
83        self.pager = None
84        self.multiplexer = None
85        self._draw_title = None
86        self._tmux_automatic_rename = None
87        self._multiplexer_title = None
88        self.browser = None
89
90        if fm is not None:
91            self.fm = fm
92
93    def setup_curses(self):
94        os.environ['ESCDELAY'] = '25'   # don't know a cleaner way
95        try:
96            self.win = curses.initscr()
97        except curses.error as ex:
98            if ex.args[0] == "setupterm: could not find terminal":
99                os.environ['TERM'] = 'linux'
100                self.win = curses.initscr()
101        self.keymaps.use_keymap('browser')
102        DisplayableContainer.__init__(self, None)
103
104    def initialize(self):
105        """initialize curses, then call setup (at the first time) and resize."""
106        self.win.leaveok(0)
107        self.win.keypad(1)
108        self.load_mode = False
109
110        curses.cbreak()
111        curses.noecho()
112        curses.halfdelay(20)
113        try:
114            curses.curs_set(int(bool(self.settings.show_cursor)))
115        except curses.error:
116            pass
117        curses.start_color()
118        try:
119            curses.use_default_colors()
120        except curses.error:
121            pass
122
123        self.settings.signal_bind('setopt.mouse_enabled', _setup_mouse)
124        self.settings.signal_bind('setopt.freeze_files', self.redraw_statusbar)
125        _setup_mouse(dict(value=self.settings.mouse_enabled))
126
127        if not self.is_set_up:
128            self.is_set_up = True
129            self.setup()
130            self.win.addstr("loading...")
131            self.win.refresh()
132            self._draw_title = curses.tigetflag('hs')  # has_status_line
133
134        self.update_size()
135        self.is_on = True
136
137        self.handle_multiplexer()
138
139        if 'vcsthread' in self.__dict__:
140            self.vcsthread.unpause()
141
142    def suspend(self):
143        """Turn off curses"""
144        if 'vcsthread' in self.__dict__:
145            self.vcsthread.pause()
146            self.vcsthread.paused.wait()
147
148        if self.fm.image_displayer:
149            self.fm.image_displayer.quit()
150
151        self.win.keypad(0)
152        curses.nocbreak()
153        curses.echo()
154        try:
155            curses.curs_set(1)
156        except curses.error:
157            pass
158        if self.settings.mouse_enabled:
159            _setup_mouse(dict(value=False))
160        curses.endwin()
161        self.is_on = False
162
163    def set_load_mode(self, boolean):
164        boolean = bool(boolean)
165        if boolean != self.load_mode:
166            self.load_mode = boolean
167
168            if boolean:
169                # don't wait for key presses in the load mode
170                curses.cbreak()
171                self.win.nodelay(1)
172            else:
173                self.win.nodelay(0)
174                # Sanitize halfdelay setting
175                halfdelay = min(255, max(1, self.settings.idle_delay // 100))
176                curses.halfdelay(halfdelay)
177
178    def destroy(self):
179        """Destroy all widgets and turn off curses"""
180        if 'vcsthread' in self.__dict__:
181            if not self.vcsthread.stop():
182                self.fm.notify('Failed to stop `UI.vcsthread`', bad=True)
183            del self.__dict__['vcsthread']
184        DisplayableContainer.destroy(self)
185
186        self.restore_multiplexer_name()
187
188        self.suspend()
189
190    def handle_mouse(self):
191        """Handles mouse input"""
192        try:
193            event = MouseEvent(curses.getmouse())
194        except curses.error:
195            return
196        if not self.console.visible:
197            DisplayableContainer.click(self, event)
198
199    def handle_key(self, key):
200        """Handles key input"""
201        self.hint()
202
203        if key < 0:
204            self.keybuffer.clear()
205
206        elif not DisplayableContainer.press(self, key):
207            self.keymaps.use_keymap('browser')
208            self.press(key)
209
210    def press(self, key):
211        keybuffer = self.keybuffer
212        self.status.clear_message()
213
214        keybuffer.add(key)
215        self.fm.hide_bookmarks()
216        self.browser.draw_hints = not keybuffer.finished_parsing \
217            and keybuffer.finished_parsing_quantifier
218
219        if keybuffer.result is not None:
220            try:
221                self.fm.execute_console(
222                    keybuffer.result,
223                    wildcards=keybuffer.wildcards,
224                    quantifier=keybuffer.quantifier,
225                )
226            finally:
227                if keybuffer.finished_parsing:
228                    keybuffer.clear()
229        elif keybuffer.finished_parsing:
230            keybuffer.clear()
231            return False
232        return True
233
234    def handle_keys(self, *keys):
235        for key in keys:
236            self.handle_key(key)
237
238    def handle_input(self):  # pylint: disable=too-many-branches
239        key = self.win.getch()
240        if key == curses.KEY_ENTER:
241            key = ord('\n')
242        if key == 27 or (key >= 128 and key < 256):
243            # Handle special keys like ALT+X or unicode here:
244            keys = [key]
245            previous_load_mode = self.load_mode
246            self.set_load_mode(True)
247            for _ in range(4):
248                getkey = self.win.getch()
249                if getkey != -1:
250                    keys.append(getkey)
251            if len(keys) == 1:
252                keys.append(-1)
253            elif keys[0] == 27:
254                keys[0] = ALT_KEY
255            if self.settings.xterm_alt_key:
256                if len(keys) == 2 and keys[1] in range(127, 256):
257                    if keys[0] == 195:
258                        keys = [ALT_KEY, keys[1] - 64]
259                    elif keys[0] == 194:
260                        keys = [ALT_KEY, keys[1] - 128]
261            self.handle_keys(*keys)
262            self.set_load_mode(previous_load_mode)
263            if self.settings.flushinput and not self.console.visible:
264                curses.flushinp()
265        else:
266            # Handle simple key presses, CTRL+X, etc here:
267            if key >= 0:
268                if self.settings.flushinput and not self.console.visible:
269                    curses.flushinp()
270                if key == curses.KEY_MOUSE:
271                    self.handle_mouse()
272                elif key == curses.KEY_RESIZE:
273                    self.update_size()
274                else:
275                    if not self.fm.input_is_blocked():
276                        self.handle_key(key)
277            elif key == -1 and not os.isatty(sys.stdin.fileno()):
278                # STDIN has been closed
279                self.fm.exit()
280
281    def setup(self):
282        """Build up the UI by initializing widgets."""
283        from ranger.gui.widgets.titlebar import TitleBar
284        from ranger.gui.widgets.console import Console
285        from ranger.gui.widgets.statusbar import StatusBar
286        from ranger.gui.widgets.taskview import TaskView
287        from ranger.gui.widgets.pager import Pager
288
289        # Create a titlebar
290        self.titlebar = TitleBar(self.win)
291        self.add_child(self.titlebar)
292
293        # Create the browser view
294        self.settings.signal_bind('setopt.viewmode', self._set_viewmode)
295        self._viewmode = None
296        # The following line sets self.browser implicitly through the signal
297        self.viewmode = self.settings.viewmode
298        self.add_child(self.browser)
299
300        # Create the process manager
301        self.taskview = TaskView(self.win)
302        self.taskview.visible = False
303        self.add_child(self.taskview)
304
305        # Create the status bar
306        self.status = StatusBar(self.win, self.browser.main_column)
307        self.add_child(self.status)
308
309        # Create the console
310        self.console = Console(self.win)
311        self.add_child(self.console)
312        self.console.visible = False
313
314        # Create the pager
315        self.pager = Pager(self.win)
316        self.pager.visible = False
317        self.add_child(self.pager)
318
319    @lazy_property
320    def vcsthread(self):
321        """VCS thread"""
322        from ranger.ext.vcs import VcsThread
323        thread = VcsThread(self)
324        thread.start()
325        return thread
326
327    def redraw(self):
328        """Redraw all widgets"""
329        self.redrawlock.wait()
330        self.redrawlock.clear()
331        self.poke()
332
333        # determine which widgets are shown
334        if self.console.wait_for_command_input or self.console.question_queue:
335            self.console.focused = True
336            self.console.visible = True
337            self.status.visible = False
338        else:
339            self.console.focused = False
340            self.console.visible = False
341            self.status.visible = True
342
343        self.draw()
344        self.finalize()
345        self.redrawlock.set()
346
347    def redraw_window(self):
348        """Redraw the window. This only calls self.win.redrawwin()."""
349        self.win.erase()
350        self.win.redrawwin()
351        self.win.refresh()
352        self.win.redrawwin()
353        self.need_redraw = True
354
355    def update_size(self):
356        """resize all widgets"""
357        self.termsize = self.win.getmaxyx()
358        y, x = self.termsize
359
360        self.browser.resize(self.settings.status_bar_on_top and 2 or 1, 0, y - 2, x)
361        self.taskview.resize(1, 0, y - 2, x)
362        self.pager.resize(1, 0, y - 2, x)
363        self.titlebar.resize(0, 0, 1, x)
364        self.status.resize(self.settings.status_bar_on_top and 1 or y - 1, 0, 1, x)
365        self.console.resize(y - 1, 0, 1, x)
366
367    def draw(self):
368        """Draw all objects in the container"""
369        self.win.touchwin()
370        DisplayableContainer.draw(self)
371        if self._draw_title and self.settings.update_title:
372            cwd = self.fm.thisdir.path
373            if self.settings.tilde_in_titlebar \
374               and (cwd == self.fm.home_path
375                    or cwd.startswith(self.fm.home_path + "/")):
376                cwd = '~' + cwd[len(self.fm.home_path):]
377            if self.settings.shorten_title:
378                split = cwd.rsplit(os.sep, self.settings.shorten_title)
379                if os.sep in split[0]:
380                    cwd = os.sep.join(split[1:])
381            try:
382                fixed_cwd = cwd.encode('utf-8', 'surrogateescape'). \
383                    decode('utf-8', 'replace')
384                escapes = [
385                    curses.tigetstr('tsl').decode('latin-1'),
386                    ESCAPE_ICON_TITLE
387                ]
388                bel = curses.tigetstr('fsl').decode('latin-1')
389                fmt_tups = [(e, fixed_cwd, bel) for e in escapes]
390            except UnicodeError:
391                pass
392            else:
393                for fmt_tup in fmt_tups:
394                    sys.stdout.write("%sranger:%s%s" % fmt_tup)
395                    sys.stdout.flush()
396
397        self.win.refresh()
398
399    def finalize(self):
400        """Finalize every object in container and refresh the window"""
401        DisplayableContainer.finalize(self)
402        self.win.refresh()
403
404    def draw_images(self):
405        if self.pager.visible:
406            self.pager.draw_image()
407        elif self.browser.pager:
408            if self.browser.pager.visible:
409                self.browser.pager.draw_image()
410            else:
411                self.browser.columns[-1].draw_image()
412
413    def close_pager(self):
414        if self.console.visible:
415            self.console.focused = True
416        self.pager.close()
417        self.pager.visible = False
418        self.pager.focused = False
419        self.browser.visible = True
420
421    def open_pager(self):
422        self.browser.columns[-1].clear_image(force=True)
423        if self.console.focused:
424            self.console.focused = False
425        self.pager.open()
426        self.pager.visible = True
427        self.pager.focused = True
428        self.browser.visible = False
429        return self.pager
430
431    def open_embedded_pager(self):
432        self.browser.open_pager()
433        for column in self.browser.columns:
434            if column == self.browser.main_column:
435                break
436            column.level_shift(amount=1)
437        return self.browser.pager
438
439    def close_embedded_pager(self):
440        self.browser.close_pager()
441        for column in self.browser.columns:
442            column.level_restore()
443
444    def open_console(self, string='', prompt=None, position=None):
445        if self.console.open(string, prompt=prompt, position=position):
446            self.status.msg = None
447
448    def close_console(self):
449        self.console.close()
450        self.close_pager()
451
452    def open_taskview(self):
453        self.browser.columns[-1].clear_image(force=True)
454        self.pager.close()
455        self.pager.visible = False
456        self.pager.focused = False
457        self.console.visible = False
458        self.browser.visible = False
459        self.taskview.visible = True
460        self.taskview.focused = True
461
462    def redraw_main_column(self):
463        self.browser.main_column.need_redraw = True
464
465    def redraw_statusbar(self):
466        self.status.need_redraw = True
467
468    def close_taskview(self):
469        self.taskview.visible = False
470        self.browser.visible = True
471        self.taskview.focused = False
472
473    def throbber(self, string='.', remove=False):
474        if remove:
475            self.titlebar.throbber = type(self.titlebar).throbber
476        else:
477            self.titlebar.throbber = string
478
479    # Handles window renaming behaviour of the terminal multiplexers
480    # GNU Screen and Tmux
481    def handle_multiplexer(self):
482        if (self.settings.update_tmux_title and not self._multiplexer_title):
483            try:
484                if _in_tmux():
485                    # Stores the automatic-rename setting
486                    # prints out a warning if allow-rename isn't set in tmux
487                    try:
488                        tmux_allow_rename = check_output(
489                            ['tmux', 'show-window-options', '-v',
490                             'allow-rename']).strip()
491                    except CalledProcessError:
492                        tmux_allow_rename = 'off'
493                    if tmux_allow_rename == 'off':
494                        self.fm.notify('Warning: allow-rename not set in Tmux!',
495                                       bad=True)
496                    else:
497                        self._multiplexer_title = check_output(
498                            ['tmux', 'display-message', '-p', '#W']).strip()
499                        self._tmux_automatic_rename = check_output(
500                            ['tmux', 'show-window-options', '-v',
501                             'automatic-rename']).strip()
502                        if self._tmux_automatic_rename == 'on':
503                            check_output(['tmux', 'set-window-option',
504                                          'automatic-rename', 'off'])
505                elif _in_screen():
506                    # Stores the screen window name before renaming it
507                    # gives out a warning if $TERM is not "screen"
508                    self._multiplexer_title = check_output(
509                        ['screen', '-Q', 'title']).strip()
510            except CalledProcessError:
511                self.fm.notify("Couldn't access previous multiplexer window"
512                               " name, won't be able to restore.",
513                               bad=False)
514            if not self._multiplexer_title:
515                self._multiplexer_title = os.path.basename(
516                    os.environ.get("SHELL", "shell"))
517
518            sys.stdout.write("\033kranger\033\\")
519            sys.stdout.flush()
520
521    # Restore window name
522    def restore_multiplexer_name(self):
523        if self._multiplexer_title:
524            try:
525                if _in_tmux():
526                    if self._tmux_automatic_rename:
527                        check_output(['tmux', 'set-window-option',
528                                      'automatic-rename',
529                                      self._tmux_automatic_rename])
530                    else:
531                        check_output(['tmux', 'set-window-option', '-u',
532                                      'automatic-rename'])
533            except CalledProcessError:
534                self.fm.notify("Could not restore multiplexer window name!",
535                               bad=True)
536
537            sys.stdout.write("\033k{}\033\\".format(self._multiplexer_title))
538            sys.stdout.flush()
539
540    def hint(self, text=None):
541        self.status.hint = text
542
543    def get_pager(self):
544        if self.browser.pager and self.browser.pager.visible:
545            return self.browser.pager
546        return self.pager
547
548    def _get_viewmode(self):
549        return self._viewmode
550
551    def _set_viewmode(self, value):
552        if isinstance(value, Signal):
553            value = value.value
554        if value == '':
555            value = self.ALLOWED_VIEWMODES[0]
556        if value in self.ALLOWED_VIEWMODES:
557            if self._viewmode != value:
558                self._viewmode = value
559                new_browser = self._viewmode_to_class(value)(self.win)
560
561                if self.browser is None:
562                    self.add_child(new_browser)
563                else:
564                    old_size = self.browser.y, self.browser.x, self.browser.hei, self.browser.wid
565                    self.replace_child(self.browser, new_browser)
566                    self.browser.destroy()
567                    new_browser.resize(*old_size)
568
569                self.browser = new_browser
570                self.redraw_window()
571        else:
572            raise ValueError("Attempting to set invalid viewmode `%s`, should "
573                             "be one of `%s`." % (value, "`, `".join(self.ALLOWED_VIEWMODES)))
574
575    viewmode = property(_get_viewmode, _set_viewmode)
576
577    @staticmethod
578    def _viewmode_to_class(viewmode):
579        if viewmode == 'miller':
580            from ranger.gui.widgets.view_miller import ViewMiller
581            return ViewMiller
582        elif viewmode == 'multipane':
583            from ranger.gui.widgets.view_multipane import ViewMultipane
584            return ViewMultipane
585        return None
586