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