1# -*- coding: utf-8 -*- 2# Copyright (c) 2010-2011 Aldo Cortesi 3# Copyright (c) 2010 Philip Kranz 4# Copyright (c) 2011 Mounier Florian 5# Copyright (c) 2011 Paul Colomiets 6# Copyright (c) 2011-2012 roger 7# Copyright (c) 2011-2012, 2014 Tycho Andersen 8# Copyright (c) 2012 Dustin Lacewell 9# Copyright (c) 2012 Laurie Clark-Michalek 10# Copyright (c) 2012-2014 Craig Barnes 11# Copyright (c) 2013 Tao Sauvage 12# Copyright (c) 2014 ramnes 13# Copyright (c) 2014 Sean Vig 14# Copyright (C) 2015, Juan Riquelme González 15# 16# Permission is hereby granted, free of charge, to any person obtaining a copy 17# of this software and associated documentation files (the "Software"), to deal 18# in the Software without restriction, including without limitation the rights 19# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 20# copies of the Software, and to permit persons to whom the Software is 21# furnished to do so, subject to the following conditions: 22# 23# The above copyright notice and this permission notice shall be included in 24# all copies or substantial portions of the Software. 25# 26# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 27# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 28# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 29# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 30# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 31# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 32# SOFTWARE. 33 34import abc 35import glob 36import os 37import pickle 38import string 39from collections import deque 40from typing import List, Optional, Tuple 41 42from libqtile import bar, hook, pangocffi, utils 43from libqtile.command.base import CommandObject, SelectError 44from libqtile.command.client import InteractiveCommandClient 45from libqtile.command.interface import CommandError, QtileCommandInterface 46from libqtile.log_utils import logger 47from libqtile.widget import base 48 49 50class AbstractCompleter(metaclass=abc.ABCMeta): 51 @abc.abstractmethod 52 def __init__(self, qtile: CommandObject) -> None: 53 pass 54 55 @abc.abstractmethod 56 def actual(self) -> Optional[str]: 57 pass 58 59 @abc.abstractmethod 60 def reset(self) -> None: 61 pass 62 63 @abc.abstractmethod 64 def complete(self, txt: str) -> str: 65 """Perform the requested completion on the given text""" 66 pass # pragma: no cover 67 68 69class NullCompleter(AbstractCompleter): 70 def __init__(self, qtile) -> None: 71 self.qtile = qtile 72 73 def actual(self) -> str: 74 return "" 75 76 def reset(self) -> None: 77 pass 78 79 def complete(self, txt: str) -> str: 80 return txt 81 82 83class FileCompleter(AbstractCompleter): 84 def __init__(self, qtile, _testing=False) -> None: 85 self._testing = _testing 86 self.qtile = qtile 87 self.thisfinal = None # type: Optional[str] 88 self.lookup = None # type: Optional[List[Tuple[str, str]]] 89 self.reset() 90 91 def actual(self) -> Optional[str]: 92 return self.thisfinal 93 94 def reset(self) -> None: 95 self.lookup = None 96 97 def complete(self, txt: str) -> str: 98 """Returns the next completion for txt, or None if there is no completion""" 99 if self.lookup is None: 100 self.lookup = [] 101 if txt == "" or txt[0] not in "~/": 102 txt = "~/" + txt 103 path = os.path.expanduser(txt) 104 if os.path.isdir(path): 105 files = glob.glob(os.path.join(path, "*")) 106 prefix = txt 107 else: 108 files = glob.glob(path + "*") 109 prefix = os.path.dirname(txt) 110 prefix = prefix.rstrip("/") or "/" 111 for f in files: 112 display = os.path.join(prefix, os.path.basename(f)) 113 if os.path.isdir(f): 114 display += "/" 115 self.lookup.append((display, f)) 116 self.lookup.sort() 117 self.offset = -1 118 self.lookup.append((txt, txt)) 119 self.offset += 1 120 if self.offset >= len(self.lookup): 121 self.offset = 0 122 ret = self.lookup[self.offset] 123 self.thisfinal = ret[1] 124 return ret[0] 125 126 127class QshCompleter(AbstractCompleter): 128 def __init__(self, qtile: CommandObject) -> None: 129 q = QtileCommandInterface(qtile) 130 self.client = InteractiveCommandClient(q) 131 self.thisfinal = None # type: Optional[str] 132 self.reset() 133 134 def actual(self) -> Optional[str]: 135 return self.thisfinal 136 137 def reset(self) -> None: 138 self.lookup = None # type: Optional[List[Tuple[str, str]]] 139 self.path = '' 140 self.offset = -1 141 142 def complete(self, txt: str) -> str: 143 txt = txt.lower() 144 if self.lookup is None: 145 self.lookup = [] 146 path = txt.split('.')[:-1] 147 self.path = '.'.join(path) 148 term = txt.split('.')[-1] 149 if len(self.path) > 0: 150 self.path += '.' 151 152 contains_cmd = 'self.client.%s_contains' % self.path 153 try: 154 contains = eval(contains_cmd) 155 except AttributeError: 156 contains = [] 157 for obj in contains: 158 if obj.lower().startswith(term): 159 self.lookup.append((obj, obj)) 160 161 commands_cmd = 'self.client.%scommands()' % self.path 162 try: 163 commands = eval(commands_cmd) 164 except (CommandError, AttributeError): 165 commands = [] 166 for cmd in commands: 167 if cmd.lower().startswith(term): 168 self.lookup.append((cmd + '()', cmd + '()')) 169 170 self.offset = -1 171 self.lookup.append((term, term)) 172 173 self.offset += 1 174 if self.offset >= len(self.lookup): 175 self.offset = 0 176 ret = self.lookup[self.offset] 177 self.thisfinal = self.path + ret[0] 178 return self.path + ret[0] 179 180 181class GroupCompleter(AbstractCompleter): 182 def __init__(self, qtile: CommandObject) -> None: 183 self.qtile = qtile 184 self.thisfinal = None # type: Optional[str] 185 self.lookup = None # type: Optional[List[Tuple[str, str]]] 186 self.offset = -1 187 188 def actual(self) -> Optional[str]: 189 """Returns the current actual value""" 190 return self.thisfinal 191 192 def reset(self) -> None: 193 self.lookup = None 194 self.offset = -1 195 196 def complete(self, txt: str) -> str: 197 """Returns the next completion for txt, or None if there is no completion""" 198 txt = txt.lower() 199 if not self.lookup: 200 self.lookup = [] 201 for group in self.qtile.groups_map.keys(): # type: ignore 202 if group.lower().startswith(txt): 203 self.lookup.append((group, group)) 204 205 self.lookup.sort() 206 self.offset = -1 207 self.lookup.append((txt, txt)) 208 209 self.offset += 1 210 if self.offset >= len(self.lookup): 211 self.offset = 0 212 ret = self.lookup[self.offset] 213 self.thisfinal = ret[1] 214 return ret[0] 215 216 217class WindowCompleter(AbstractCompleter): 218 def __init__(self, qtile: CommandObject) -> None: 219 self.qtile = qtile 220 self.thisfinal = None # type: Optional[str] 221 self.lookup = None # type: Optional[List[Tuple[str, str]]] 222 self.offset = -1 223 224 def actual(self) -> Optional[str]: 225 """Returns the current actual value""" 226 return self.thisfinal 227 228 def reset(self) -> None: 229 self.lookup = None 230 self.offset = -1 231 232 def complete(self, txt: str) -> str: 233 """Returns the next completion for txt, or None if there is no completion""" 234 if self.lookup is None: 235 self.lookup = [] 236 for wid, window in self.qtile.windows_map.items(): # type: ignore 237 if window.group and window.name.lower().startswith(txt): 238 self.lookup.append((window.name, wid)) 239 240 self.lookup.sort() 241 self.offset = -1 242 self.lookup.append((txt, txt)) 243 244 self.offset += 1 245 if self.offset >= len(self.lookup): 246 self.offset = 0 247 ret = self.lookup[self.offset] 248 self.thisfinal = ret[1] 249 return ret[0] 250 251 252class CommandCompleter: 253 """ 254 Parameters 255 ========== 256 _testing : 257 disables reloading of the lookup table to make testing possible. 258 """ 259 260 DEFAULTPATH = "/bin:/usr/bin:/usr/local/bin" 261 262 def __init__(self, qtile, _testing=False): 263 self.lookup = None # type: Optional[List[Tuple[str, str]]] 264 self.offset = -1 265 self.thisfinal = None # type: Optional[str] 266 self._testing = _testing 267 268 def actual(self) -> Optional[str]: 269 """Returns the current actual value""" 270 return self.thisfinal 271 272 def executable(self, fpath: str): 273 return os.access(fpath, os.X_OK) 274 275 def reset(self) -> None: 276 self.lookup = None 277 self.offset = -1 278 279 def complete(self, txt: str) -> str: 280 """Returns the next completion for txt, or None if there is no completion""" 281 if self.lookup is None: 282 # Lookup is a set of (display value, actual value) tuples. 283 self.lookup = [] 284 if txt and txt[0] in "~/": 285 path = os.path.expanduser(txt) 286 if os.path.isdir(path): 287 files = glob.glob(os.path.join(path, "*")) 288 prefix = txt 289 else: 290 files = glob.glob(path + "*") 291 prefix = os.path.dirname(txt) 292 prefix = prefix.rstrip("/") or "/" 293 for f in files: 294 if self.executable(f): 295 display = os.path.join(prefix, os.path.basename(f)) 296 if os.path.isdir(f): 297 display += "/" 298 self.lookup.append((display, f)) 299 else: 300 dirs = os.environ.get("PATH", self.DEFAULTPATH).split(":") 301 for d in dirs: 302 try: 303 d = os.path.expanduser(d) 304 for cmd in glob.iglob(os.path.join(d, "%s*" % txt)): 305 if self.executable(cmd): 306 self.lookup.append( 307 ( 308 os.path.basename(cmd), 309 cmd 310 ), 311 ) 312 except OSError: 313 pass 314 self.lookup.sort() 315 self.offset = -1 316 self.lookup.append((txt, txt)) 317 self.offset += 1 318 if self.offset >= len(self.lookup): 319 self.offset = 0 320 ret = self.lookup[self.offset] 321 self.thisfinal = ret[1] 322 return ret[0] 323 324 325class Prompt(base._TextBox): 326 """A widget that prompts for user input 327 328 Input should be started using the ``.start_input()`` method on this class. 329 """ 330 completers = { 331 "file": FileCompleter, 332 "qshell": QshCompleter, 333 "cmd": CommandCompleter, 334 "group": GroupCompleter, 335 "window": WindowCompleter, 336 None: NullCompleter 337 } 338 orientations = base.ORIENTATION_HORIZONTAL 339 defaults = [("cursor", True, "Show a cursor"), 340 ("cursorblink", 0.5, "Cursor blink rate. 0 to disable."), 341 ("cursor_color", "bef098", 342 "Color for the cursor and text over it."), 343 ("prompt", "{prompt}: ", "Text displayed at the prompt"), 344 ("record_history", True, "Keep a record of executed commands"), 345 ("max_history", 100, 346 "Commands to keep in history. 0 for no limit."), 347 ("ignore_dups_history", False, 348 "Don't store duplicates in history"), 349 ("bell_style", "audible", 350 "Alert at the begin/end of the command history. " + 351 "Possible values: 'audible' (X11 only), 'visual' and None."), 352 ("visual_bell_color", "ff0000", 353 "Color for the visual bell (changes prompt background)."), 354 ("visual_bell_time", 0.2, 355 "Visual bell duration (in seconds).")] 356 357 def __init__(self, name="prompt", **config) -> None: 358 base._TextBox.__init__(self, "", bar.CALCULATED, **config) 359 self.add_defaults(Prompt.defaults) 360 self.name = name 361 self.active = False 362 self.completer = None # type: Optional[AbstractCompleter] 363 364 # If history record is on, get saved history or create history record 365 if self.record_history: 366 self.history_path = os.path.join(utils.get_cache_dir(), 367 'prompt_history') 368 if os.path.exists(self.history_path): 369 with open(self.history_path, 'rb') as f: 370 try: 371 self.history = pickle.load(f) 372 if self.ignore_dups_history: 373 self._dedup_history() 374 except: # noqa: E722 375 # unfortunately, pickle doesn't wrap its errors, so we 376 # can't detect what's a pickle error and what's not. 377 logger.exception("failed to load prompt history") 378 self.history = {x: deque(maxlen=self.max_history) 379 for x in self.completers} 380 381 # self.history of size does not match. 382 if len(self.history) != len(self.completers): 383 self.history = {x: deque(maxlen=self.max_history) 384 for x in self.completers} 385 386 if self.max_history != \ 387 self.history[list(self.history)[0]].maxlen: 388 self.history = {x: deque(self.history[x], 389 self.max_history) 390 for x in self.completers} 391 else: 392 self.history = {x: deque(maxlen=self.max_history) 393 for x in self.completers} 394 395 def _configure(self, qtile, bar) -> None: 396 self.markup = True 397 base._TextBox._configure(self, qtile, bar) 398 399 def f(win): 400 if self.active and not win == self.bar.window: 401 self._unfocus() 402 403 hook.subscribe.client_focus(f) 404 405 # Define key handlers (action to do when a specific key is hit) 406 keyhandlers = { 407 'Tab': self._trigger_complete, 408 'BackSpace': self._delete_char(), 409 'Delete': self._delete_char(False), 410 'KP_Delete': self._delete_char(False), 411 'Escape': self._unfocus, 412 'Return': self._send_cmd, 413 'KP_Enter': self._send_cmd, 414 'Up': self._get_prev_cmd, 415 'KP_Up': self._get_prev_cmd, 416 'Down': self._get_next_cmd, 417 'KP_Down': self._get_next_cmd, 418 'Left': self._move_cursor(), 419 'KP_Left': self._move_cursor(), 420 'Right': self._move_cursor("right"), 421 'KP_Right': self._move_cursor("right"), 422 } 423 self.keyhandlers = { 424 qtile.core.keysym_from_name(k): v for k, v in keyhandlers.items() 425 } 426 printables = {x: self._write_char for x in range(127) if 427 chr(x) in string.printable} 428 self.keyhandlers.update(printables) 429 self.tab = qtile.core.keysym_from_name("Tab") 430 431 self.bell_style: str 432 if self.bell_style == "audible" and qtile.core.name != "x11": 433 self.bell_style = "visual" 434 logger.warning("Prompt widget only supports audible bell under X11") 435 if self.bell_style == "visual": 436 self.original_background = self.background 437 438 def start_input(self, prompt, callback, complete=None, 439 strict_completer=False, allow_empty_input=False) -> None: 440 """Run the prompt 441 442 Displays a prompt and starts to take one line of keyboard input from 443 the user. When done, calls the callback with the input string as 444 argument. If history record is enabled, also allows to browse between 445 previous commands with ↑ and ↓, and execute them (untouched or 446 modified). When history is exhausted, fires an alert. It tries to 447 mimic, in some way, the shell behavior. 448 449 Parameters 450 ========== 451 complete : 452 Tab-completion. Can be None, "cmd", "file", "group", "qshell" or 453 "window". 454 prompt : 455 text displayed at the prompt, e.g. "spawn: " 456 callback : 457 function to call with returned value. 458 complete : 459 completer to use. 460 strict_completer : 461 When True the return value wil be the exact completer result where 462 available. 463 allow_empty_input : 464 When True, an empty value will still call the callback function 465 """ 466 467 if self.cursor and self.cursorblink and not self.active: 468 self.timeout_add(self.cursorblink, self._blink) 469 self.display = self.prompt.format(prompt=prompt) 470 self.display = pangocffi.markup_escape_text(self.display) 471 self.active = True 472 self.user_input = "" 473 self.archived_input = "" 474 self.show_cursor = self.cursor 475 self.cursor_position = 0 476 self.callback = callback 477 self.completer = self.completers[complete](self.qtile) 478 self.strict_completer = strict_completer 479 self.allow_empty_input = allow_empty_input 480 self._update() 481 self.bar.widget_grab_keyboard(self) 482 if self.record_history: 483 self.completer_history = self.history[complete] 484 self.position = len(self.completer_history) 485 486 def calculate_length(self) -> int: 487 if self.text: 488 width = min( 489 self.layout.width, 490 self.bar.width 491 ) + self.actual_padding * 2 492 return width 493 else: 494 return 0 495 496 def _blink(self) -> None: 497 self.show_cursor = not self.show_cursor 498 self._update() 499 if self.active: 500 self.timeout_add(self.cursorblink, self._blink) 501 502 def _highlight_text(self, text) -> str: 503 color = utils.hex(self.cursor_color) 504 text = '<span foreground="{0}">{1}</span>'.format(color, text) 505 if self.show_cursor: 506 text = '<u>{}</u>'.format(text) 507 return text 508 509 def _update(self) -> None: 510 if self.active: 511 self.text = self.archived_input or self.user_input 512 cursor = pangocffi.markup_escape_text(" ") 513 if self.cursor_position < len(self.text): 514 txt1 = self.text[:self.cursor_position] 515 txt2 = self.text[self.cursor_position] 516 txt3 = self.text[self.cursor_position + 1:] 517 for text in (txt1, txt2, txt3): 518 text = pangocffi.markup_escape_text(text) 519 txt2 = self._highlight_text(txt2) 520 self.text = "{0}{1}{2}{3}".format(txt1, txt2, txt3, cursor) 521 else: 522 self.text = pangocffi.markup_escape_text(self.text) 523 self.text += self._highlight_text(cursor) 524 self.text = self.display + self.text 525 else: 526 self.text = "" 527 self.bar.draw() 528 529 def _trigger_complete(self) -> None: 530 # Trigger the auto completion in user input 531 assert self.completer is not None 532 self.user_input = self.completer.complete(self.user_input) 533 self.cursor_position = len(self.user_input) 534 535 def _history_to_input(self) -> None: 536 # Move actual command (when exploring history) to user input and update 537 # history position (right after the end) 538 if self.archived_input: 539 self.user_input = self.archived_input 540 self.archived_input = "" 541 self.position = len(self.completer_history) 542 543 def _insert_before_cursor(self, charcode) -> None: 544 # Insert a character (given their charcode) in input, before the cursor 545 txt1 = self.user_input[:self.cursor_position] 546 txt2 = self.user_input[self.cursor_position:] 547 self.user_input = txt1 + chr(charcode) + txt2 548 self.cursor_position += 1 549 550 def _delete_char(self, backspace=True): 551 # Return a function that deletes character from the input text. 552 # If backspace is True, function will emulate backspace, else Delete. 553 def f(): 554 self._history_to_input() 555 step = -1 if backspace else 0 556 if not backspace and self.cursor_position == len(self.user_input): 557 self._alert() 558 elif len(self.user_input) > 0 and self.cursor_position + step > -1: 559 txt1 = self.user_input[:self.cursor_position + step] 560 txt2 = self.user_input[self.cursor_position + step + 1:] 561 self.user_input = txt1 + txt2 562 if step: 563 self.cursor_position += step 564 else: 565 self._alert() 566 return f 567 568 def _write_char(self): 569 # Add pressed (legal) char key to user input. 570 # No LookupString in XCB... oh, the shame! Unicode users beware! 571 self._history_to_input() 572 self._insert_before_cursor(self.key) 573 574 def _unfocus(self): 575 # Remove focus from the widget 576 self.active = False 577 self._update() 578 self.bar.widget_ungrab_keyboard() 579 580 def _send_cmd(self): 581 # Send the prompted text for execution 582 self._unfocus() 583 if self.strict_completer: 584 self.user_input = self.actual_value or self.user_input 585 del self.actual_value 586 self._history_to_input() 587 if self.user_input or self.allow_empty_input: 588 # If history record is activated, also save command in history 589 if self.record_history: 590 # ensure no dups in history 591 if self.ignore_dups_history and (self.user_input in self.completer_history): 592 self.completer_history.remove(self.user_input) 593 self.position -= 1 594 595 self.completer_history.append(self.user_input) 596 597 if self.position < self.max_history: 598 self.position += 1 599 os.makedirs(os.path.dirname(self.history_path), exist_ok=True) 600 with open(self.history_path, mode='wb') as f: 601 pickle.dump(self.history, f, protocol=2) 602 self.callback(self.user_input) 603 604 def _alert(self): 605 # Fire an alert (audible or visual), if bell style is not None. 606 if self.bell_style == "audible": 607 self.qtile.core.conn.conn.core.Bell(0) 608 elif self.bell_style == "visual": 609 self.background = self.visual_bell_color 610 self.timeout_add(self.visual_bell_time, self._stop_visual_alert) 611 612 def _stop_visual_alert(self): 613 self.background = self.original_background 614 self._update() 615 616 def _get_prev_cmd(self): 617 # Get the previous command in history. 618 # If there isn't more previous commands, ring system bell 619 if self.record_history: 620 if not self.position: 621 self._alert() 622 else: 623 self.position -= 1 624 self.archived_input = self.completer_history[self.position] 625 self.cursor_position = len(self.archived_input) 626 627 def _get_next_cmd(self): 628 # Get the next command in history. 629 # If the last command was already reached, ring system bell. 630 if self.record_history: 631 if self.position == len(self.completer_history): 632 self._alert() 633 elif self.position < len(self.completer_history): 634 self.position += 1 635 if self.position == len(self.completer_history): 636 self.archived_input = "" 637 else: 638 self.archived_input = self.completer_history[self.position] 639 self.cursor_position = len(self.archived_input) 640 641 def _cursor_to_left(self): 642 # Move cursor to left, if possible 643 if self.cursor_position: 644 self.cursor_position -= 1 645 else: 646 self._alert() 647 648 def _cursor_to_right(self): 649 # move cursor to right, if possible 650 command = self.archived_input or self.user_input 651 if self.cursor_position < len(command): 652 self.cursor_position += 1 653 else: 654 self._alert() 655 656 def _move_cursor(self, direction="left"): 657 # Move the cursor to left or right, according to direction 658 if direction == "left": 659 return self._cursor_to_left 660 elif direction == "right": 661 return self._cursor_to_right 662 663 def _get_keyhandler(self, k): 664 # Return the action (a function) to do according the pressed key (k). 665 self.key = k 666 if k in self.keyhandlers: 667 if k != self.tab: 668 self.actual_value = self.completer.actual() 669 self.completer.reset() 670 return self.keyhandlers[k] 671 672 def process_key_press(self, keysym: int): 673 """Key press handler for the minibuffer. 674 675 Currently only supports ASCII characters. 676 """ 677 handle_key = self._get_keyhandler(keysym) 678 679 if handle_key: 680 handle_key() 681 del self.key 682 self._update() 683 684 def cmd_fake_keypress(self, key: str) -> None: 685 self.process_key_press(self.qtile.core.keysym_from_name(key)) 686 687 def cmd_info(self): 688 """Returns a dictionary of info for this object""" 689 return dict( 690 name=self.name, 691 width=self.width, 692 text=self.text, 693 active=self.active, 694 ) 695 696 def cmd_exec_general( 697 self, prompt, object_name, cmd_name, selector=None, completer=None): 698 """ 699 Execute a cmd of any object. For example layout, group, window, widget 700 , etc with a string that is obtained from start_input. 701 702 Parameters 703 ========== 704 prompt : 705 Text displayed at the prompt. 706 object_name : 707 Name of a object in Qtile. This string has to be 'layout', 'widget', 708 'bar', 'window' or 'screen'. 709 cmd_name : 710 Execution command of selected object using object_name and selector. 711 selector : 712 This value select a specific object within a object list that is 713 obtained by object_name. 714 If this value is None, current object is selected. e.g. current layout, 715 current window and current screen. 716 completer: 717 Completer to use. 718 719 config example: 720 Key([alt, 'shift'], 'a', 721 lazy.widget['prompt'].exec_general( 722 'section(add)', 723 'layout', 724 'add_section')) 725 """ 726 try: 727 obj = self.qtile.select([(object_name, selector)]) 728 except SelectError: 729 logger.warning("cannot select a object") 730 return 731 cmd = obj.command(cmd_name) 732 if not cmd: 733 logger.warning("command not found") 734 return 735 736 def f(args): 737 if args: 738 cmd(args) 739 740 self.start_input(prompt, f, completer) 741 742 def _dedup_history(self): 743 """Filter the history deque, clearing all duplicate values.""" 744 self.history = {x: self._dedup_deque(self.history[x]) 745 for x in self.completers} 746 747 def _dedup_deque(self, dq): 748 return deque(_LastUpdatedOrderedDict.fromkeys(dq)) 749 750 751class _LastUpdatedOrderedDict(dict): 752 """Store items in the order the keys were last added.""" 753 754 def __setitem__(self, key, value): 755 if key in self: 756 del self[key] 757 super().__setitem__(key, value) 758