about summary refs log blame commit diff stats
path: root/ranger/gui/ui.py
blob: db12d6dc0b184e79a7aad139b25824e9b0dbe533 (plain) (tree)
1
2
3
4
5
6
7
8
9

                                                                 
 
                                                                  
 
         
          
                
             
                                         
 
                                                      
                                                                    
                                                  
                                     
                                         
 



                                             

                                                                  

                                                                        

                                                                       

                             
                                                

 
                       
                                                             
 
 
                         



                                   




                                                                              
                                                                         
                                        

                           
 
 
               
                                      



                                            
                                                  


                                              

                                                                                 
                                             
 




                     
                                                                                   

                                              
                                           
                             
 





                             
                               
                               
                                          
                                      

                           






                                                                  
                                  
                                                                  









                                                                                
 




                                                                 
                            

                            

                                       
                            
                

                                                                       
                                                                               






                                                             
                                                                        
 


                          

                                 


                                        

                             

                                        
                                        
 


                                          




                              
                            
















                                                             


                                                                             


                                                     
                                        


                                                                         
                                          
 
                                       
 
                      




                                                 
                            





                                                   
                   














                                                                  
                                                     


                                        




                                                    











                                              
                                                                
                              

                                   
                                           



                                                             
                              
                                         
                                
















                                                                     
                        








                                                                         



                                                                 

                                                      





                                                          
                           



                                          

                                                                        

                                                                            




















                                                                   






                                            
 

                                

                               













                                                                              
                             

















                                                                                    
                                                                                   







                                                           


                                                                






                                                                       
                                              
                           
                                                             
                                     

                                                              
                                                                 
                                
                    
                 


                                                               






                                                                       

                          

                                   
                                



                                                     









                                       
                                                        





























                                                                       
                                                        










                                                   


                                      










                                                                 


                                                                    




                                                                             
                        










                                                                                
                                                                   




                                                                      









                                                                            

                                                           
 


                                                 

                                       













                                                                            
                                                                              
                              
 

                               

                        
                                                             
                                     
                         











                                             

                                                                      


                                               
                                                                                                 
                                                                 
                                          
                                                 
 
                                          
                                    

                                                                               
                                                                                              


                                                     

                                     


                                                                 
                                     

                                                                       
                   
# This file is part of ranger, the console file manager.
# License: GNU GPL version 3, see the file "AUTHORS" for details.

from __future__ import (absolute_import, division, print_function)

import os
import sys
import threading
import curses
from subprocess import CalledProcessError

from ranger.ext.get_executables import get_executables
from ranger.ext.keybinding_parser import KeyBuffer, KeyMaps, ALT_KEY
from ranger.ext.lazy_property import lazy_property
from ranger.ext.signals import Signal
from ranger.ext.spawn import check_output

from .displayable import DisplayableContainer
from .mouse_event import MouseEvent


MOUSEMASK = curses.ALL_MOUSE_EVENTS | curses.REPORT_MOUSE_POSITION

# This escape is not available with a capname from terminfo unlike
# tsl (to_status_line), so it's hardcoded here. It's used just like tsl,
# but it sets the icon title (WM_ICON_NAME) instead of the window title
# (WM_NAME).
ESCAPE_ICON_TITLE = '\033]1;'

_ASCII = ''.join(chr(c) for c in range(32, 127))


def ascii_only(string):
    return ''.join(c if c in _ASCII else '?' for c in string)


def _setup_mouse(signal):
    if signal['value']:
        curses.mousemask(MOUSEMASK)
        curses.mouseinterval(0)

        # This line solves this problem:
        # If a mouse click triggers an action that disables curses and
        # starts curses again, (e.g. running a ## file by clicking on its
        # preview) and the next key is another mouse click, the bstate of this
        # mouse event will be invalid.  (atm, invalid bstates are recognized
        # as scroll-down, so this avoids an erroneous scroll-down action)
        curses.ungetmouse(0, 0, 0, 0, 0)
    else:
        curses.mousemask(0)


def _in_tmux():
    return (os.environ.get("TMUX", "")
            and 'tmux' in get_executables())


def _in_screen():
    return ('screen' in os.environ.get("TERM", "")
            and 'screen' in get_executables())


class UI(  # pylint: disable=too-many-instance-attributes,too-many-public-methods
        DisplayableContainer):
    ALLOWED_VIEWMODES = 'miller', 'multipane'

    is_set_up = False
    load_mode = False
    is_on = False
    termsize = None

    def __init__(self, env=None, fm=None):  # pylint: disable=super-init-not-called
        self.keybuffer = KeyBuffer()
        self.keymaps = KeyMaps(self.keybuffer)
        self.redrawlock = threading.Event()
        self.redrawlock.set()

        self.titlebar = None
        self._viewmode = None
        self.taskview = None
        self.status = None
        self.console = None
        self.pager = None
        self.multiplexer = None
        self._draw_title = None
        self._tmux_automatic_rename = None
        self._multiplexer_title = None
        self.browser = None

        if fm is not None:
            self.fm = fm

    def setup_curses(self):
        os.environ['ESCDELAY'] = '25'   # don't know a cleaner way
        try:
            self.win = curses.initscr()
        except curses.error as ex:
            if ex.args[0] == "setupterm: could not find terminal":
                os.environ['TERM'] = 'linux'
                self.win = curses.initscr()
        self.keymaps.use_keymap('browser')
        DisplayableContainer.__init__(self, None)

    def initialize(self):
        """initialize curses, then call setup (at the first time) and resize."""
        self.win.leaveok(0)
        self.win.keypad(1)
        self.load_mode = False

        curses.cbreak()
        curses.noecho()
        curses.halfdelay(20)
        try:
            curses.curs_set(int(bool(self.settings.show_cursor)))
        except curses.error:
            pass
        curses.start_color()
        try:
            curses.use_default_colors()
        except curses.error:
            pass

        self.settings.signal_bind('setopt.mouse_enabled', _setup_mouse)
        self.settings.signal_bind('setopt.freeze_files', self.redraw_statusbar)
        _setup_mouse(dict(value=self.settings.mouse_enabled))

        if not self.is_set_up:
            self.is_set_up = True
            self.setup()
            self.win.addstr("loading...")
            self.win.refresh()
            self._draw_title = curses.tigetflag('hs')  # has_status_line

        self.update_size()
        self.is_on = True

        self.handle_multiplexer()

        if 'vcsthread' in self.__dict__:
            self.vcsthread.unpause()

    def suspend(self):
        """Turn off curses"""
        if 'vcsthread' in self.__dict__:
            self.vcsthread.pause()
            self.vcsthread.paused.wait()

        if self.fm.image_displayer:
            self.fm.image_displayer.quit()

        self.win.keypad(0)
        curses.nocbreak()
        curses.echo()
        try:
            curses.curs_set(1)
        except curses.error:
            pass
        if self.settings.mouse_enabled:
            _setup_mouse(dict(value=False))
        curses.endwin()
        self.is_on = False

    def set_load_mode(self, boolean):
        boolean = bool(boolean)
        if boolean != self.load_mode:
            self.load_mode = boolean

            if boolean:
                # don't wait for key presses in the load mode
                curses.cbreak()
                self.win.nodelay(1)
            else:
                self.win.nodelay(0)
                # Sanitize halfdelay setting
                halfdelay = min(255, max(1, self.settings.idle_delay // 100))
                curses.halfdelay(halfdelay)

    def destroy(self):
        """Destroy all widgets and turn off curses"""
        if 'vcsthread' in self.__dict__:
            if not self.vcsthread.stop():
                self.fm.notify('Failed to stop `UI.vcsthread`', bad=True)
            del self.__dict__['vcsthread']
        DisplayableContainer.destroy(self)

        self.restore_multiplexer_name()

        self.suspend()

    def handle_mouse(self):
        """Handles mouse input"""
        try:
            event = MouseEvent(curses.getmouse())
        except curses.error:
            return
        if not self.console.visible:
            DisplayableContainer.click(self, event)

    def handle_key(self, key):
        """Handles key input"""
        self.hint()

        if key < 0:
            self.keybuffer.clear()

        elif not DisplayableContainer.press(self, key):
            self.keymaps.use_keymap('browser')
            self.press(key)

    def press(self, key):
        keybuffer = self.keybuffer
        self.status.clear_message()

        keybuffer.add(key)
        self.fm.hide_bookmarks()
        self.browser.draw_hints = not keybuffer.finished_parsing \
            and keybuffer.finished_parsing_quantifier

        if keybuffer.result is not None:
            try:
                self.fm.execute_console(
                    keybuffer.result,
                    wildcards=keybuffer.wildcards,
                    quantifier=keybuffer.quantifier,
                )
            finally:
                if keybuffer.finished_parsing:
                    keybuffer.clear()
        elif keybuffer.finished_parsing:
            keybuffer.clear()
            return False
        return True

    def handle_keys(self, *keys):
        for key in keys:
            self.handle_key(key)

    def handle_input(self):  # pylint: disable=too-many-branches
        key = self.win.getch()
        if key == curses.KEY_ENTER:
            key = ord('\n')
        if key == 27 or (128 <= key < 256):
            # Handle special keys like ALT+X or unicode here:
            keys = [key]
            previous_load_mode = self.load_mode
            self.set_load_mode(True)
            for _ in range(4):
                getkey = self.win.getch()
                if getkey != -1:
                    keys.append(getkey)
            if len(keys) == 1:
                keys.append(-1)
            elif keys[0] == 27:
                keys[0] = ALT_KEY
            if self.settings.xterm_alt_key:
                if len(keys) == 2 and keys[1] in range(127, 256):
                    if keys[0] == 195:
                        keys = [ALT_KEY, keys[1] - 64]
                    elif keys[0] == 194:
                        keys = [ALT_KEY, keys[1] - 128]
            self.handle_keys(*keys)
            self.set_load_mode(previous_load_mode)
            if self.settings.flushinput and not self.console.visible:
                curses.flushinp()
        else:
            # Handle simple key presses, CTRL+X, etc here:
            if key >= 0:
                if self.settings.flushinput and not self.console.visible:
                    curses.flushinp()
                if key == curses.KEY_MOUSE:
                    self.handle_mouse()
                elif key == curses.KEY_RESIZE:
                    self.update_size()
                else:
                    if not self.fm.input_is_blocked():
                        self.handle_key(key)
            elif key == -1 and not os.isatty(sys.stdin.fileno()):
                # STDIN has been closed
                self.fm.exit()

    def setup(self):
        """Build up the UI by initializing widgets."""
        from ranger.gui.widgets.titlebar import TitleBar
        from ranger.gui.widgets.console import Console
        from ranger.gui.widgets.statusbar import StatusBar
        from ranger.gui.widgets.taskview import TaskView
        from ranger.gui.widgets.pager import Pager

        # Create a titlebar
        self.titlebar = TitleBar(self.win)
        self.add_child(self.titlebar)

        # Create the browser view
        self.settings.signal_bind('setopt.viewmode', self._set_viewmode)
        self._viewmode = None
        # The following line sets self.browser implicitly through the signal
        self.viewmode = self.settings.viewmode
        self.add_child(self.browser)

        # Create the process manager
        self.taskview = TaskView(self.win)
        self.taskview.visible = False
        self.add_child(self.taskview)

        # Create the status bar
        self.status = StatusBar(self.win, self.browser.main_column)
        self.add_child(self.status)

        # Create the console
        self.console = Console(self.win)
        self.add_child(self.console)
        self.console.visible = False

        # Create the pager
        self.pager = Pager(self.win)
        self.pager.visible = False
        self.add_child(self.pager)

    @lazy_property
    def vcsthread(self):
        """VCS thread"""
        from ranger.ext.vcs import VcsThread
        thread = VcsThread(self)
        thread.start()
        return thread

    def redraw(self):
        """Redraw all widgets"""
        self.redrawlock.wait()
        self.redrawlock.clear()
        self.poke()

        # determine which widgets are shown
        if self.console.wait_for_command_input or self.console.question_queue:
            self.console.focused = True
            self.console.visible = True
            self.status.visible = False
        else:
            self.console.focused = False
            self.console.visible = False
            self.status.visible = True

        self.draw()
        self.finalize()
        self.redrawlock.set()

    def redraw_window(self):
        """Redraw the window. This only calls self.win.redrawwin()."""
        self.win.erase()
        self.win.redrawwin()
        self.win.refresh()
        self.win.redrawwin()
        self.need_redraw = True

    def update_size(self):
        """resize all widgets"""
        self.termsize = self.win.getmaxyx()
        y, x = self.termsize

        self.browser.resize(self.settings.status_bar_on_top and 2 or 1, 0, y - 2, x)
        self.taskview.resize(1, 0, y - 2, x)
        self.pager.resize(1, 0, y - 2, x)
        self.titlebar.resize(0, 0, 1, x)
        self.status.resize(self.settings.status_bar_on_top and 1 or y - 1, 0, 1, x)
        self.console.resize(y - 1, 0, 1, x)

    def draw(self):
        """Draw all objects in the container"""
        self.win.touchwin()
        DisplayableContainer.draw(self)
        if self._draw_title and self.settings.update_title:
            cwd = self.fm.thisdir.path
            if self.settings.tilde_in_titlebar \
               and (cwd == self.fm.home_path
                    or cwd.startswith(self.fm.home_path + "/")):
                cwd = '~' + cwd[len(self.fm.home_path):]
            if self.settings.shorten_title:
                split = cwd.rsplit(os.sep, self.settings.shorten_title)
                if os.sep in split[0]:
                    cwd = os.sep.join(split[1:])
            try:
                fixed_cwd = cwd.encode('utf-8', 'surrogateescape'). \
                    decode('utf-8', 'replace')
                escapes = [
                    curses.tigetstr('tsl').decode('latin-1'),
                    ESCAPE_ICON_TITLE
                ]
                bel = curses.tigetstr('fsl').decode('latin-1')
                fmt_tups = [(e, fixed_cwd, bel) for e in escapes]
            except UnicodeError:
                pass
            else:
                for fmt_tup in fmt_tups:
                    sys.stdout.write("%sranger:%s%s" % fmt_tup)
                    sys.stdout.flush()

        self.win.refresh()

    def finalize(self):
        """Finalize every object in container and refresh the window"""
        DisplayableContainer.finalize(self)
        self.win.refresh()

    def draw_images(self):
        if self.pager.visible:
            self.pager.draw_image()
        elif self.browser.pager:
            if self.browser.pager.visible:
                self.browser.pager.draw_image()
            else:
                self.browser.columns[-1].draw_image()

    def close_pager(self):
        if self.console.visible:
            self.console.focused = True
        self.pager.close()
        self.pager.visible = False
        self.pager.focused = False
        self.browser.visible = True

    def open_pager(self):
        self.browser.columns[-1].clear_image(force=True)
        if self.console.focused:
            self.console.focused = False
        self.pager.open()
        self.pager.visible = True
        self.pager.focused = True
        self.browser.visible = False
        return self.pager

    def open_embedded_pager(self):
        self.browser.open_pager()
        for column in self.browser.columns:
            if column == self.browser.main_column:
                break
            column.level_shift(amount=1)
        return self.browser.pager

    def close_embedded_pager(self):
        self.browser.close_pager()
        for column in self.browser.columns:
            column.level_restore()

    def open_console(self, string='', prompt=None, position=None):
        if self.console.open(string, prompt=prompt, position=position):
            self.status.msg = None

    def close_console(self):
        self.console.close()
        self.close_pager()

    def open_taskview(self):
        self.browser.columns[-1].clear_image(force=True)
        self.pager.close()
        self.pager.visible = False
        self.pager.focused = False
        self.console.visible = False
        self.browser.visible = False
        self.taskview.visible = True
        self.taskview.focused = True

    def redraw_main_column(self):
        self.browser.main_column.need_redraw = True

    def redraw_statusbar(self):
        self.status.need_redraw = True

    def close_taskview(self):
        self.taskview.visible = False
        self.browser.visible = True
        self.taskview.focused = False

    def throbber(self, string='.', remove=False):
        if remove:
            self.titlebar.throbber = type(self.titlebar).throbber
        else:
            self.titlebar.throbber = string

    # Handles window renaming behaviour of the terminal multiplexers
    # GNU Screen and Tmux
    def handle_multiplexer(self):
        if (self.settings.update_tmux_title and not self._multiplexer_title):
            try:
                if _in_tmux():
                    # Stores the automatic-rename setting
                    # prints out a warning if allow-rename isn't set in tmux
                    try:
                        tmux_allow_rename = check_output(
                            ['tmux', 'show-window-options', '-v',
                             'allow-rename']).strip()
                    except CalledProcessError:
                        tmux_allow_rename = 'off'
                    if tmux_allow_rename == 'off':
                        self.fm.notify('Warning: allow-rename not set in Tmux!',
                                       bad=True)
                    else:
                        self._multiplexer_title = check_output(
                            ['tmux', 'display-message', '-p', '#W']).strip()
                        self._tmux_automatic_rename = check_output(
                            ['tmux', 'show-window-options', '-v',
                             'automatic-rename']).strip()
                        if self._tmux_automatic_rename == 'on':
                            check_output(['tmux', 'set-window-option',
                                          'automatic-rename', 'off'])
                elif _in_screen():
                    # Stores the screen window name before renaming it
                    # gives out a warning if $TERM is not "screen"
                    self._multiplexer_title = check_output(
                        ['screen', '-Q', 'title']).strip()
            except CalledProcessError:
                self.fm.notify("Couldn't access previous multiplexer window"
                               " name, won't be able to restore.",
                               bad=False)
            if not self._multiplexer_title:
                self._multiplexer_title = os.path.basename(
                    os.environ.get("SHELL", "shell"))

            sys.stdout.write("\033kranger\033\\")
            sys.stdout.flush()

    # Restore window name
    def restore_multiplexer_name(self):
        if self._multiplexer_title:
            try:
                if _in_tmux():
                    if self._tmux_automatic_rename:
                        check_output(['tmux', 'set-window-option',
                                      'automatic-rename',
                                      self._tmux_automatic_rename])
                    else:
                        check_output(['tmux', 'set-window-option', '-u',
                                      'automatic-rename'])
            except CalledProcessError:
                self.fm.notify("Could not restore multiplexer window name!",
                               bad=True)

            sys.stdout.write("\033k{0}\033\\".format(self._multiplexer_title))
            sys.stdout.flush()

    def hint(self, text=None):
        self.status.hint = text

    def get_pager(self):
        if self.browser.pager and self.browser.pager.visible:
            return self.browser.pager
        return self.pager

    def _get_viewmode(self):
        return self._viewmode

    def _set_viewmode(self, value):
        if isinstance(value, Signal):
            value = value.value
        if value == '':
            value = self.ALLOWED_VIEWMODES[0]
        if value in self.ALLOWED_VIEWMODES:
            if self._viewmode != value:
                self._viewmode = value
                new_browser = self._viewmode_to_class(value)(self.win)

                if self.browser is None:
                    self.add_child(new_browser)
                else:
                    old_size = self.browser.y, self.browser.x, self.browser.hei, self.browser.wid
                    self.replace_child(self.browser, new_browser)
                    self.browser.destroy()
                    new_browser.resize(*old_size)

                self.browser = new_browser
                self.redraw_window()
        else:
            raise ValueError("Attempting to set invalid viewmode `%s`, should "
                             "be one of `%s`." % (value, "`, `".join(self.ALLOWED_VIEWMODES)))

    viewmode = property(_get_viewmode, _set_viewmode)

    @staticmethod
    def _viewmode_to_class(viewmode):
        if viewmode == 'miller':
            from ranger.gui.widgets.view_miller import ViewMiller
            return ViewMiller
        elif viewmode == 'multipane':
            from ranger.gui.widgets.view_multipane import ViewMultipane
            return ViewMultipane
        return None