Allow defining actions to take when pasting

Fixes #4873
This commit is contained in:
Kovid Goyal
2022-03-24 17:22:46 +05:30
parent cb7aed3234
commit 3c67e991c2
10 changed files with 114 additions and 33 deletions

View File

@@ -41,6 +41,8 @@ Detailed list of changes
clone local shell and editor configuration on remote machines, and automatic
re-use of existing connections to avoid connection setup latency.
- When pasting URLs at shell prompts automatically quote them. Also allow filtering pasted text and confirm pastes. See :opt:`paste_actions` for details. (:iss:`4873`)
- macOS: When using Apple's less as the pager for viewing scrollback strip out OSC codes as it cant parse them (:iss:`4788`)
- diff kitten: Fix incorrect rendering in rare circumstances when scrolling after changing the context size (:iss:`4831`)

View File

@@ -35,7 +35,7 @@ your machine).
# get the kitty window into which to paste answer
w = boss.window_id_map.get(target_window_id)
if w is not None:
w.paste(answer)
w.paste_text(answer)
Now in :file:`kitty.conf` add the lines::

View File

@@ -787,7 +787,7 @@ def handle_result(args: List[str], data: Dict[str, Any], target_window_id: int,
if program == '-':
w = boss.window_id_map.get(target_window_id)
if w is not None:
w.paste(joined_text())
w.paste_text(joined_text())
elif program == '@':
set_clipboard_string(joined_text())
elif program == '*':

View File

@@ -591,7 +591,7 @@ def main(args: List[str]) -> Optional[str]:
def handle_result(args: List[str], current_char: str, target_window_id: int, boss: BossType) -> None:
w = boss.window_id_map.get(target_window_id)
if w is not None:
w.paste(current_char)
w.paste_text(current_char)
if __name__ == '__main__':

View File

@@ -1240,7 +1240,7 @@ class Boss:
text = ' '.join(map(shlex.quote, urls))
else:
text = '\n'.join(urls)
w.paste(text)
w.paste_text(text)
@ac('win', 'Focus the nth OS window')
def nth_os_window(self, num: int = 1) -> None:
@@ -1590,7 +1590,7 @@ class Boss:
if text:
w = self.active_window
if w is not None:
w.paste(text)
w.paste_with_actions(text)
@ac('cp', 'Paste from the clipboard to the active window')
def paste_from_clipboard(self) -> None:

View File

@@ -447,6 +447,21 @@ system clipboard.
'''
)
opt(
'paste_actions', 'quote-urls-at-prompt', option_type='paste_actions',
long_text='''
A comma separated list of actions to take when pasting text into the terminal.
Possibilities are:
* quote-urls-at-prompt: If the text being pasted is a URL and the cursor is at a shell prompt,
automatically quote the URL (needs :ref:`shell_integration`).
* confirm: Confirm the paste if bracketed paste mode is not active or there is more
a large amount of text being pasted.
* filter: Run the filter_paste() function from the file :file:`paste-actions.py` in
the kitty config directory on the pasted text. The text returned by the function will be actually pasted.
'''
)
opt('strip_trailing_spaces', 'never',
choices=('always', 'never', 'smart'),
long_text='''

View File

@@ -12,9 +12,9 @@ from kitty.options.utils import (
deprecated_hide_window_decorations_aliases, deprecated_macos_show_window_title_in_menubar_alias,
deprecated_send_text, disable_ligatures, edge_width, env, font_features, hide_window_decorations,
macos_option_as_alt, macos_titlebar_color, narrow_symbols, optional_edge_width, parse_map,
parse_mouse_map, resize_draw_strategy, scrollback_lines, scrollback_pager_history_size,
shell_integration, store_multiple, symbol_map, tab_activity_symbol, tab_bar_edge,
tab_bar_margin_height, tab_bar_min_tabs, tab_fade, tab_font_style, tab_separator,
parse_mouse_map, paste_actions, resize_draw_strategy, scrollback_lines,
scrollback_pager_history_size, shell_integration, store_multiple, symbol_map, tab_activity_symbol,
tab_bar_edge, tab_bar_margin_height, tab_bar_min_tabs, tab_fade, tab_font_style, tab_separator,
tab_title_template, titlebar_color, to_cursor_shape, to_font_size, to_layout_names, to_modifiers,
url_prefixes, url_style, visual_window_select_characters, window_border_width, window_size
)
@@ -1086,6 +1086,9 @@ class Parser:
def open_url_with(self, val: str, ans: typing.Dict[str, typing.Any]) -> None:
ans['open_url_with'] = to_cmdline(val)
def paste_actions(self, val: str, ans: typing.Dict[str, typing.Any]) -> None:
ans['paste_actions'] = paste_actions(val)
def placement_strategy(self, val: str, ans: typing.Dict[str, typing.Any]) -> None:
val = val.lower()
if val not in self.choices_for_placement_strategy:

View File

@@ -390,6 +390,7 @@ option_names = ( # {{{
'mouse_map',
'narrow_symbols',
'open_url_with',
'paste_actions',
'placement_strategy',
'pointer_shape_when_dragging',
'pointer_shape_when_grabbed',
@@ -536,6 +537,7 @@ class Options:
mark3_foreground: Color = Color(0, 0, 0)
mouse_hide_wait: float = 0.0 if is_macos else 3.0
open_url_with: typing.List[str] = ['default']
paste_actions: typing.FrozenSet[str] = frozenset({'quote-urls-at-prompt'})
placement_strategy: choices_for_placement_strategy = 'center'
pointer_shape_when_dragging: choices_for_pointer_shape_when_dragging = 'beam'
pointer_shape_when_grabbed: choices_for_pointer_shape_when_grabbed = 'arrow'

View File

@@ -808,6 +808,15 @@ def shell_integration(x: str) -> FrozenSet[str]:
return q
def paste_actions(x: str) -> FrozenSet[str]:
s = frozenset({'quote-urls-at-prompt', 'confirm', 'filter'})
q = frozenset(x.lower().split(','))
if not q.issubset(s):
log_error(f'Invalid paste actions: {q - s}, ignoring')
return q & s or frozenset({'invalid'})
return q
def action_alias(val: str) -> Iterable[Tuple[str, str]]:
parts = val.split(maxsplit=1)
if len(parts) > 1:

View File

@@ -20,7 +20,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
from .constants import appname, is_macos, wakeup, config_dir
from .fast_data_types import (
BGIMAGE_PROGRAM, BLIT_PROGRAM, CELL_BG_PROGRAM, CELL_FG_PROGRAM,
CELL_PROGRAM, CELL_SPECIAL_PROGRAM, CURSOR_BEAM, CURSOR_BLOCK,
@@ -42,7 +42,7 @@ from .notify import NotificationCommand, handle_notification_cmd
from .options.types import Options
from .rgb import to_color
from .terminfo import get_capabilities
from .types import MouseEvent, WindowGeometry, ac
from .types import MouseEvent, WindowGeometry, ac, run_once
from .typing import BossType, ChildType, EdgeLiteral, TabType, TypedDict
from .utils import (
get_primary_selection, kitty_ansi_sanitizer_pat, load_shaders, log_error,
@@ -339,6 +339,23 @@ def setup_colors(screen: Screen, opts: Options) -> None:
)
@run_once
def load_paste_filter() -> Callable[[str], str]:
import runpy
import traceback
try:
m = runpy.run_path(os.path.join(config_dir, 'paste-actions.py'))
func: Callable[[str], str] = m['filter_paste']
except Exception as e:
if not isinstance(e, FileNotFoundError):
traceback.print_exc()
log_error(f'Failed to load custom draw_tab function with error: {e}')
def func(text: str) -> str:
return text
return func
def text_sanitizer(as_ansi: bool, add_wrap_markers: bool) -> Callable[[str], str]:
pat = kitty_ansi_sanitizer_pat()
ansi, wrap_markers = not as_ansi, not add_wrap_markers
@@ -1131,13 +1148,13 @@ class Window:
def paste_selection(self) -> None:
txt = get_boss().current_primary_selection()
if txt:
self.paste(txt)
self.paste_with_actions(txt)
@ac('mouse', 'Paste the current primary selection or the clipboard if no selection is present')
def paste_selection_or_clipboard(self) -> None:
txt = get_boss().current_primary_selection_or_clipboard()
if txt:
self.paste(txt)
self.paste_with_actions(txt)
@ac('mouse', '''
Select clicked command output
@@ -1236,6 +1253,58 @@ class Window:
path = resolve_custom_file(path) if path else ''
set_window_logo(self.os_window_id, self.tab_id, self.id, path, position or '', alpha)
def paste_with_actions(self, text: str) -> None:
if self.destroyed or not text:
return
opts = get_options()
if 'filter' in opts.paste_actions:
text = load_paste_filter()(text)
if not text:
return
if 'quote-urls-at-prompt' in opts.paste_actions and self.at_prompt:
prefixes = '|'.join(opts.url_prefixes)
if re.match(f'{prefixes}:', text) is not None:
import shlex
text = shlex.quote(text)
btext = text.encode('utf-8')
if 'confirm' in opts.paste_actions:
msg = ''
limit = 16 * 1024
if not self.screen.in_bracketed_paste_mode:
msg = _('Pasting text into shells that do not support bracketed paste can be dangerous.')
elif len(btext) > limit:
msg = _('Pasting very large amounts of text ({} bytes) can be slow.').format(len(btext))
if msg:
get_boss().confirm(msg + _(' Are you sure?'), partial(self.handle_paste_confirmation, btext), window=self)
return
self.paste_text(btext)
def handle_paste_confirmation(self, btext: bytes, confirmed: bool) -> None:
if confirmed:
self.paste_text(btext)
def paste_bytes(self, text: Union[str, bytes]) -> None:
# paste raw bytes without any processing
if isinstance(text, str):
text = text.encode('utf-8')
self.screen.paste_bytes(text)
def paste_text(self, text: Union[str, bytes]) -> None:
if text and not self.destroyed:
if isinstance(text, str):
text = text.encode('utf-8')
if self.screen.in_bracketed_paste_mode:
while True:
new_text = text.replace(b'\033[201~', b'').replace(b'\x9b201~', b'')
if len(text) == len(new_text):
break
text = new_text
else:
# Workaround for broken editors like nano that cannot handle
# newlines in pasted text see https://github.com/kovidgoyal/kitty/issues/994
text = text.replace(b'\r\n', b'\n').replace(b'\n', b'\r')
self.screen.paste(text)
# actions {{{
@ac('cp', 'Show scrollback in a pager like less')
@@ -1275,28 +1344,9 @@ class Window:
def show_last_visited_command_output(self) -> None:
self.show_cmd_output(CommandOutput.last_visited, 'Last visited command output')
def paste_bytes(self, text: Union[str, bytes]) -> None:
# paste raw bytes without any processing
if isinstance(text, str):
text = text.encode('utf-8')
self.screen.paste_bytes(text)
@ac('cp', 'Paste the specified text into the current window')
def paste(self, text: Union[str, bytes]) -> None:
if text and not self.destroyed:
if isinstance(text, str):
text = text.encode('utf-8')
if self.screen.in_bracketed_paste_mode:
while True:
new_text = text.replace(b'\033[201~', b'').replace(b'\x9b201~', b'')
if len(text) == len(new_text):
break
text = new_text
else:
# Workaround for broken editors like nano that cannot handle
# newlines in pasted text see https://github.com/kovidgoyal/kitty/issues/994
text = text.replace(b'\r\n', b'\n').replace(b'\n', b'\r')
self.screen.paste(text)
def paste(self, text: str) -> None:
self.paste_with_actions(text)
@ac('cp', 'Copy the selected text from the active window to the clipboard')
def copy_to_clipboard(self) -> None: