From ade38870a06df32c4c62fbd264a13931cabbc5f3 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Tue, 12 Apr 2022 19:55:20 +0530 Subject: [PATCH] Allow using boolean operators when matching windows or tabs --- docs/changelog.rst | 5 +++ docs/remote-control.rst | 18 ++++++++ kitty/boss.py | 93 ++++++++++------------------------------- kitty/cli.py | 2 +- kitty/rc/base.py | 6 ++- kitty/tabs.py | 26 +++++++++--- kitty/window.py | 37 ++++++++++++++-- 7 files changed, 103 insertions(+), 84 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 0681454ff..5f7c04aa7 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -34,6 +34,11 @@ mouse anywhere in the current command to move the cursor there. See Detailed list of changes ------------------------------------- +0.25.1 [future] +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +- Remote control: Allow using :ref:`Boolean operators ` when constructing queries to match windows or tabs + 0.25.0 [2022-04-11] ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/docs/remote-control.rst b/docs/remote-control.rst index 36c762bed..f3cd566f0 100644 --- a/docs/remote-control.rst +++ b/docs/remote-control.rst @@ -178,6 +178,24 @@ The remote control protocol If you wish to develop your own client to talk to |kitty|, you can use the :doc:`protocol specification `. + +.. _search_syntax: + +Matching windows and tabs +---------------------------- + +Many remote control operations operate on windows or tabs. To select these, +the :code:`--match` option is often used. This allows matching using various +sophisticated criteria such as title, ids, cmdlines, etc. These criteria are +expressions of the form :code:`field:query`. Where field is the field against +which to match and query is the expression to match. They can be further +combined using Boolean operators, best illustrated with some examples:: + + title:"My special window" or id:43 + title:bash and env:USER=kovid + not id:1 + (id:2 or id:3) and title:something + .. toctree:: :hidden: diff --git a/kitty/boss.py b/kitty/boss.py index 753032a08..586d62fef 100644 --- a/kitty/boss.py +++ b/kitty/boss.py @@ -12,7 +12,7 @@ from gettext import gettext as _ from time import monotonic from typing import ( Any, Callable, Container, Dict, Iterable, Iterator, List, Optional, - Sequence, Tuple, Union + Sequence, Set, Tuple, Union ) from weakref import WeakValueDictionary @@ -62,7 +62,7 @@ from .utils import ( platform_window_id, remove_socket_file, safe_print, set_primary_selection, single_instance, startup_notification_handler, which ) -from .window import CommandOutput, CwdRequest, MatchPatternType, Window +from .window import CommandOutput, CwdRequest, Window class OSWindowDict(TypedDict): @@ -340,42 +340,14 @@ class Boss: yield from tab def match_windows(self, match: str) -> Iterator[Window]: - try: - field, exp = match.split(':', 1) - except ValueError: - return - if field == 'num': - tab = self.active_tab - if tab is not None: - try: - w = tab.get_nth_window(int(exp)) - except Exception: - return - if w is not None: - yield w - return - if field == 'recent': - tab = self.active_tab - if tab is not None: - try: - num = int(exp) - except Exception: - return - w = self.window_id_map.get(tab.nth_active_window_id(num)) - if w is not None: - yield w - return - if field != 'env': - pat: MatchPatternType = re.compile(exp) - else: - kp, vp = exp.partition('=')[::2] - if vp: - pat = re.compile(kp), re.compile(vp) - else: - pat = re.compile(kp), None - for window in self.all_windows: - if window.matches(field, pat): - yield window + from .search_query_parser import search + tab = self.active_tab + + def get_matches(location: str, query: str, candidates: Set[int]) -> Set[int]: + return {wid for wid in candidates if self.window_id_map[wid].matches_query(location, query, tab)} + + for wid in search(match, ('id', 'title', 'pid', 'cwd', 'cmdline', 'num', 'env', 'recent',), set(self.window_id_map), get_matches): + yield self.window_id_map[wid] def tab_for_window(self, window: Window) -> Optional[Tab]: for tab in self.all_tabs: @@ -385,41 +357,18 @@ class Boss: return None def match_tabs(self, match: str) -> Iterator[Tab]: - try: - field, exp = match.split(':', 1) - except ValueError: - return - pat = re.compile(exp) + from .search_query_parser import search + tm = self.active_tab_manager + tim = {t.id: t for t in self.all_tabs} + + def get_matches(location: str, query: str, candidates: Set[int]) -> Set[int]: + return {wid for wid in candidates if tim[wid].matches_query(location, query, tm)} + found = False - if field in ('title', 'id'): - for tab in self.all_tabs: - if tab.matches(field, pat): - yield tab - found = True - elif field in ('window_id', 'window_title'): - wf = field.split('_')[1] - tabs = {self.tab_for_window(w) for w in self.match_windows(f'{wf}:{exp}')} - for q in tabs: - if q: - found = True - yield q - elif field == 'index': - tm = self.active_tab_manager - if tm is not None and len(tm.tabs) > 0: - idx = (int(pat.pattern) + len(tm.tabs)) % len(tm.tabs) - found = True - yield tm.tabs[idx] - elif field == 'recent': - tm = self.active_tab_manager - if tm is not None and len(tm.tabs) > 0: - try: - num = int(exp) - except Exception: - return - q = tm.nth_active_tab(num) - if q is not None: - found = True - yield q + for tid in search(match, ('id', 'index', 'title', 'window_id', 'window_title', 'pid', 'cwd', 'env', 'cmdline', 'recent',), set(tim), get_matches): + found = True + yield tim[tid] + if not found: tabs = {self.tab_for_window(w) for w in self.match_windows(match)} for q in tabs: diff --git a/kitty/cli.py b/kitty/cli.py index cbf6af831..d7bbbb446 100644 --- a/kitty/cli.py +++ b/kitty/cli.py @@ -151,7 +151,7 @@ def doc(x: str) -> str: @role def ref(x: str) -> str: - return re.sub(r'\s*<.+?>', '', x) + return re.sub(r'\s*<\S+?>', '', x) OptionSpecSeq = List[Union[str, OptionDict]] diff --git a/kitty/rc/base.py b/kitty/rc/base.py index 7060aacd9..eb17c86b7 100644 --- a/kitty/rc/base.py +++ b/kitty/rc/base.py @@ -77,7 +77,8 @@ MATCH_WINDOW_OPTION = '''\ --match -m The window to match. Match specifications are of the form: :italic:`field:regexp`. Where field can be one of: id, title, pid, cwd, cmdline, num, env and recent. -You can use the :italic:`ls` command to get a list of windows. Note that for +You can use the :italic:`ls` command to get a list of windows. Expressions can +be :ref:`combined using Boolean operators `. Note that for numeric fields such as id, pid, recent and num the expression is interpreted as a number, not a regular expression. The field num refers to the window position in the current tab, starting from zero and counting clockwise (this is the same as the order in which the @@ -93,7 +94,8 @@ MATCH_TAB_OPTION = '''\ The tab to match. Match specifications are of the form: :italic:`field:regexp`. Where field can be one of: id, index, title, window_id, window_title, pid, cwd, env, cmdline and recent. -You can use the :italic:`ls` command to get a list of tabs. Note that for +You can use the :italic:`ls` command to get a list of tabs. Expressions can +be :ref:`combined using Boolean operators `. Note that for numeric fields such as id, recent, index and pid the expression is interpreted as a number, not a regular expression. When using title or id, first a matching tab is looked for and if not found a matching window is looked for, and the tab diff --git a/kitty/tabs.py b/kitty/tabs.py index dc450cb3b..7e13f9386 100644 --- a/kitty/tabs.py +++ b/kitty/tabs.py @@ -2,6 +2,7 @@ # License: GPL v3 Copyright: 2016, Kovid Goyal import os +import re import stat import weakref from collections import deque @@ -10,7 +11,7 @@ from operator import attrgetter from time import monotonic from typing import ( Any, Deque, Dict, Generator, Iterable, Iterator, List, NamedTuple, - Optional, Pattern, Sequence, Set, Tuple, Union + Optional, Sequence, Set, Tuple, Union ) from .borders import Border, Borders @@ -687,11 +688,26 @@ class Tab: # {{{ for w in self: yield w.as_dict(is_focused=w is active_window, is_self=w is self_window) - def matches(self, field: str, pat: 'Pattern[str]') -> bool: - if field == 'id': - return bool(pat.pattern == str(self.id)) + def matches_query(self, field: str, query: str, active_tab_manager: Optional['TabManager'] = None) -> bool: if field == 'title': - return pat.search(self.effective_title) is not None + return re.search(query, self.effective_title) is not None + if field == 'id': + return query == str(self.id) + if field in ('window_id', 'window_title'): + field = field.partition('_')[-1] + for w in self: + if w.matches_query(field, query): + return True + return False + if field == 'index': + if active_tab_manager and len(active_tab_manager.tabs): + idx = (int(query) + len(active_tab_manager.tabs)) % len(active_tab_manager.tabs) + return active_tab_manager.tabs[idx] is self + return False + if field == 'recent': + if active_tab_manager and len(active_tab_manager.tabs): + return self is active_tab_manager.nth_active_tab(int(query)) + return False return False def __iter__(self) -> Iterator[Window]: diff --git a/kitty/window.py b/kitty/window.py index bc7cadcdf..10ec8321b 100644 --- a/kitty/window.py +++ b/kitty/window.py @@ -7,8 +7,9 @@ import re import sys import weakref from collections import deque +from contextlib import suppress from enum import Enum, IntEnum, auto -from functools import partial +from functools import lru_cache, partial from gettext import gettext as _ from itertools import chain from time import monotonic @@ -20,7 +21,7 @@ from typing import ( from .child import ProcessDesc from .cli_stub import CLIOptions from .config import build_ansi_color_table -from .constants import appname, is_macos, wakeup, config_dir +from .constants import appname, config_dir, is_macos, wakeup from .fast_data_types import ( BGIMAGE_PROGRAM, BLIT_PROGRAM, CELL_BG_PROGRAM, CELL_FG_PROGRAM, CELL_PROGRAM, CELL_SPECIAL_PROGRAM, CURSOR_BEAM, CURSOR_BLOCK, @@ -113,6 +114,19 @@ def process_title_from_child(title: str, is_base64: bool) -> str: return sanitize_title(title) +@lru_cache(maxsize=64) +def compile_match_query(exp: str, is_simple: bool = True) -> MatchPatternType: + if is_simple: + pat: MatchPatternType = re.compile(exp) + else: + kp, vp = exp.partition('=')[::2] + if vp: + pat = re.compile(kp), re.compile(vp) + else: + pat = re.compile(kp), None + return pat + + class WindowDict(TypedDict): id: int is_focused: bool @@ -621,9 +635,9 @@ class Window: assert not isinstance(pat, tuple) if field in ('id', 'window_id'): - return True if pat.pattern == str(self.id) else False + return pat.pattern == str(self.id) if field == 'pid': - return True if pat.pattern == str(self.child.pid) else False + return pat.pattern == str(self.child.pid) if field == 'title': return pat.search(self.override_title or self.title) is not None if field in 'cwd': @@ -635,6 +649,21 @@ class Window: return False return False + def matches_query(self, field: str, query: str, active_tab: Optional[TabType] = None) -> bool: + if field in ('num', 'recent'): + if active_tab is not None: + try: + q = int(query) + except Exception: + return False + with suppress(Exception): + if field == 'num': + return active_tab.get_nth_window(q) is self + return active_tab.nth_active_window_id(q) == self.id + return False + pat = compile_match_query(query, field != 'env') + return self.matches(field, pat) + def set_visible_in_layout(self, val: bool) -> None: val = bool(val) if val is not self.is_visible_in_layout: