Allow using boolean operators when matching windows or tabs

This commit is contained in:
Kovid Goyal 2022-04-12 19:55:20 +05:30
parent 11bc1b100c
commit ade38870a0
No known key found for this signature in database
GPG Key ID: 06BC317B515ACE7C
7 changed files with 103 additions and 84 deletions

View File

@ -34,6 +34,11 @@ mouse anywhere in the current command to move the cursor there. See
Detailed list of changes Detailed list of changes
------------------------------------- -------------------------------------
0.25.1 [future]
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
- Remote control: Allow using :ref:`Boolean operators <search_syntax>` when constructing queries to match windows or tabs
0.25.0 [2022-04-11] 0.25.0 [2022-04-11]
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

View File

@ -178,6 +178,24 @@ The remote control protocol
If you wish to develop your own client to talk to |kitty|, you If you wish to develop your own client to talk to |kitty|, you
can use the :doc:`protocol specification <rc_protocol>`. can use the :doc:`protocol specification <rc_protocol>`.
.. _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:: .. toctree::
:hidden: :hidden:

View File

@ -12,7 +12,7 @@ from gettext import gettext as _
from time import monotonic from time import monotonic
from typing import ( from typing import (
Any, Callable, Container, Dict, Iterable, Iterator, List, Optional, Any, Callable, Container, Dict, Iterable, Iterator, List, Optional,
Sequence, Tuple, Union Sequence, Set, Tuple, Union
) )
from weakref import WeakValueDictionary from weakref import WeakValueDictionary
@ -62,7 +62,7 @@ from .utils import (
platform_window_id, remove_socket_file, safe_print, set_primary_selection, platform_window_id, remove_socket_file, safe_print, set_primary_selection,
single_instance, startup_notification_handler, which single_instance, startup_notification_handler, which
) )
from .window import CommandOutput, CwdRequest, MatchPatternType, Window from .window import CommandOutput, CwdRequest, Window
class OSWindowDict(TypedDict): class OSWindowDict(TypedDict):
@ -340,42 +340,14 @@ class Boss:
yield from tab yield from tab
def match_windows(self, match: str) -> Iterator[Window]: def match_windows(self, match: str) -> Iterator[Window]:
try: from .search_query_parser import search
field, exp = match.split(':', 1)
except ValueError:
return
if field == 'num':
tab = self.active_tab tab = self.active_tab
if tab is not None:
try: def get_matches(location: str, query: str, candidates: Set[int]) -> Set[int]:
w = tab.get_nth_window(int(exp)) return {wid for wid in candidates if self.window_id_map[wid].matches_query(location, query, tab)}
except Exception:
return for wid in search(match, ('id', 'title', 'pid', 'cwd', 'cmdline', 'num', 'env', 'recent',), set(self.window_id_map), get_matches):
if w is not None: yield self.window_id_map[wid]
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
def tab_for_window(self, window: Window) -> Optional[Tab]: def tab_for_window(self, window: Window) -> Optional[Tab]:
for tab in self.all_tabs: for tab in self.all_tabs:
@ -385,41 +357,18 @@ class Boss:
return None return None
def match_tabs(self, match: str) -> Iterator[Tab]: def match_tabs(self, match: str) -> Iterator[Tab]:
try: from .search_query_parser import search
field, exp = match.split(':', 1) tm = self.active_tab_manager
except ValueError: tim = {t.id: t for t in self.all_tabs}
return
pat = re.compile(exp) 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 found = False
if field in ('title', 'id'): for tid in search(match, ('id', 'index', 'title', 'window_id', 'window_title', 'pid', 'cwd', 'env', 'cmdline', 'recent',), set(tim), get_matches):
for tab in self.all_tabs:
if tab.matches(field, pat):
yield tab
found = True found = True
elif field in ('window_id', 'window_title'): yield tim[tid]
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
if not found: if not found:
tabs = {self.tab_for_window(w) for w in self.match_windows(match)} tabs = {self.tab_for_window(w) for w in self.match_windows(match)}
for q in tabs: for q in tabs:

View File

@ -151,7 +151,7 @@ def doc(x: str) -> str:
@role @role
def ref(x: str) -> str: def ref(x: str) -> str:
return re.sub(r'\s*<.+?>', '', x) return re.sub(r'\s*<\S+?>', '', x)
OptionSpecSeq = List[Union[str, OptionDict]] OptionSpecSeq = List[Union[str, OptionDict]]

View File

@ -77,7 +77,8 @@ MATCH_WINDOW_OPTION = '''\
--match -m --match -m
The window to match. Match specifications are of the form: 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. :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 <search_syntax>`. Note that for
numeric fields such as id, pid, recent and num the expression is interpreted as a number, 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, 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 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: The tab to match. Match specifications are of the form:
:italic:`field:regexp`. Where field can be one of: :italic:`field:regexp`. Where field can be one of:
id, index, title, window_id, window_title, pid, cwd, env, cmdline and recent. 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 <search_syntax>`. Note that for
numeric fields such as id, recent, index and pid the expression is interpreted as a number, 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 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 looked for and if not found a matching window is looked for, and the tab

View File

@ -2,6 +2,7 @@
# License: GPL v3 Copyright: 2016, Kovid Goyal <kovid at kovidgoyal.net> # License: GPL v3 Copyright: 2016, Kovid Goyal <kovid at kovidgoyal.net>
import os import os
import re
import stat import stat
import weakref import weakref
from collections import deque from collections import deque
@ -10,7 +11,7 @@ from operator import attrgetter
from time import monotonic from time import monotonic
from typing import ( from typing import (
Any, Deque, Dict, Generator, Iterable, Iterator, List, NamedTuple, Any, Deque, Dict, Generator, Iterable, Iterator, List, NamedTuple,
Optional, Pattern, Sequence, Set, Tuple, Union Optional, Sequence, Set, Tuple, Union
) )
from .borders import Border, Borders from .borders import Border, Borders
@ -687,11 +688,26 @@ class Tab: # {{{
for w in self: for w in self:
yield w.as_dict(is_focused=w is active_window, is_self=w is self_window) 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: def matches_query(self, field: str, query: str, active_tab_manager: Optional['TabManager'] = None) -> bool:
if field == 'id':
return bool(pat.pattern == str(self.id))
if field == 'title': 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 return False
def __iter__(self) -> Iterator[Window]: def __iter__(self) -> Iterator[Window]:

View File

@ -7,8 +7,9 @@ import re
import sys import sys
import weakref import weakref
from collections import deque from collections import deque
from contextlib import suppress
from enum import Enum, IntEnum, auto from enum import Enum, IntEnum, auto
from functools import partial from functools import lru_cache, partial
from gettext import gettext as _ from gettext import gettext as _
from itertools import chain from itertools import chain
from time import monotonic from time import monotonic
@ -20,7 +21,7 @@ from typing import (
from .child import ProcessDesc from .child import ProcessDesc
from .cli_stub import CLIOptions from .cli_stub import CLIOptions
from .config import build_ansi_color_table 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 ( from .fast_data_types import (
BGIMAGE_PROGRAM, BLIT_PROGRAM, CELL_BG_PROGRAM, CELL_FG_PROGRAM, BGIMAGE_PROGRAM, BLIT_PROGRAM, CELL_BG_PROGRAM, CELL_FG_PROGRAM,
CELL_PROGRAM, CELL_SPECIAL_PROGRAM, CURSOR_BEAM, CURSOR_BLOCK, 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) 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): class WindowDict(TypedDict):
id: int id: int
is_focused: bool is_focused: bool
@ -621,9 +635,9 @@ class Window:
assert not isinstance(pat, tuple) assert not isinstance(pat, tuple)
if field in ('id', 'window_id'): 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': if field == 'pid':
return True if pat.pattern == str(self.child.pid) else False return pat.pattern == str(self.child.pid)
if field == 'title': if field == 'title':
return pat.search(self.override_title or self.title) is not None return pat.search(self.override_title or self.title) is not None
if field in 'cwd': if field in 'cwd':
@ -635,6 +649,21 @@ class Window:
return False return False
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: def set_visible_in_layout(self, val: bool) -> None:
val = bool(val) val = bool(val)
if val is not self.is_visible_in_layout: if val is not self.is_visible_in_layout: