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
-------------------------------------
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]
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

View File

@ -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 <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::
:hidden:

View File

@ -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':
from .search_query_parser import search
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
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
for tid in search(match, ('id', 'index', 'title', 'window_id', 'window_title', 'pid', 'cwd', 'env', 'cmdline', 'recent',), set(tim), get_matches):
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
yield tim[tid]
if not found:
tabs = {self.tab_for_window(w) for w in self.match_windows(match)}
for q in tabs:

View File

@ -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]]

View File

@ -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 <search_syntax>`. 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 <search_syntax>`. 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

View File

@ -2,6 +2,7 @@
# License: GPL v3 Copyright: 2016, Kovid Goyal <kovid at kovidgoyal.net>
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]:

View File

@ -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: