Remove the python diff kitten

This commit is contained in:
Kovid Goyal 2023-03-27 11:46:22 +05:30
parent fb9d95038d
commit d30091034a
No known key found for this signature in database
GPG Key ID: 06BC317B515ACE7C
20 changed files with 216 additions and 2907 deletions

View File

@ -163,15 +163,6 @@
}
},
{
"name": "pygments",
"unix": {
"filename": "Pygments-2.11.2.tar.gz",
"hash": "sha256:4e426f72023d88d03b2fa258de560726ce890ff3b630f88c21cbb8b2503b8c6a",
"urls": ["pypi"]
}
},
{
"name": "libpng",
"unix": {

View File

@ -40,7 +40,6 @@ Run-time dependencies:
* ``fontconfig`` (not needed on macOS)
* ``libcanberra`` (not needed on macOS)
* ``ImageMagick`` (optional, needed to display uncommon image formats in the terminal)
* ``pygments`` (optional, needed for syntax highlighting in ``kitty +kitten diff``)
Build-time dependencies:

View File

@ -31,11 +31,7 @@ Major Features
Installation
---------------
Simply :ref:`install kitty <quickstart>`. You also need to have either the `git
<https://git-scm.com/>`__ program or the :program:`diff` program installed.
Additionally, for syntax highlighting to work, `pygments
<https://pygments.org/>`__ must be installed (note that pygments is included in
the official kitty binary builds).
Simply :ref:`install kitty <quickstart>`.
Usage

View File

@ -1 +0,0 @@
See https://sw.kovidgoyal.net/kitty/kittens/diff/

View File

@ -1,8 +1,5 @@
class GlobalData:
def __init__(self) -> None:
self.title = ''
self.cmd = ''
from typing import Dict
global_data = GlobalData
def syntax_aliases(x: str) -> Dict[str, str]:
return {}

View File

@ -1,233 +0,0 @@
#!/usr/bin/env python3
# License: GPL v3 Copyright: 2018, Kovid Goyal <kovid at kovidgoyal.net>
import os
from contextlib import suppress
from fnmatch import fnmatch
from functools import lru_cache
from hashlib import md5
from typing import TYPE_CHECKING, Dict, Iterator, List, Optional, Sequence, Set, Tuple, Union
from kitty.guess_mime_type import guess_type
from kitty.utils import control_codes_pat
if TYPE_CHECKING:
from .highlight import DiffHighlight
path_name_map: Dict[str, str] = {}
remote_dirs: Dict[str, str] = {}
def add_remote_dir(val: str) -> None:
remote_dirs[val] = os.path.basename(val).rpartition('-')[-1]
class Segment:
__slots__ = ('start', 'end', 'start_code', 'end_code')
def __init__(self, start: int, start_code: str):
self.start = start
self.start_code = start_code
self.end: Optional[int] = None
self.end_code: Optional[str] = None
def __repr__(self) -> str:
return f'Segment(start={self.start!r}, start_code={self.start_code!r}, end={self.end!r}, end_code={self.end_code!r})'
class Collection:
ignore_names: Tuple[str, ...] = ()
def __init__(self) -> None:
self.changes: Dict[str, str] = {}
self.renames: Dict[str, str] = {}
self.adds: Set[str] = set()
self.removes: Set[str] = set()
self.all_paths: List[str] = []
self.type_map: Dict[str, str] = {}
self.added_count = self.removed_count = 0
def add_change(self, left_path: str, right_path: str) -> None:
self.changes[left_path] = right_path
self.all_paths.append(left_path)
self.type_map[left_path] = 'diff'
def add_rename(self, left_path: str, right_path: str) -> None:
self.renames[left_path] = right_path
self.all_paths.append(left_path)
self.type_map[left_path] = 'rename'
def add_add(self, right_path: str) -> None:
self.adds.add(right_path)
self.all_paths.append(right_path)
self.type_map[right_path] = 'add'
if isinstance(data_for_path(right_path), str):
self.added_count += len(lines_for_path(right_path))
def add_removal(self, left_path: str) -> None:
self.removes.add(left_path)
self.all_paths.append(left_path)
self.type_map[left_path] = 'removal'
if isinstance(data_for_path(left_path), str):
self.removed_count += len(lines_for_path(left_path))
def finalize(self) -> None:
def key(x: str) -> str:
return path_name_map.get(x, '')
self.all_paths.sort(key=key)
def __iter__(self) -> Iterator[Tuple[str, str, Optional[str]]]:
for path in self.all_paths:
typ = self.type_map[path]
if typ == 'diff':
data: Optional[str] = self.changes[path]
elif typ == 'rename':
data = self.renames[path]
else:
data = None
yield path, typ, data
def __len__(self) -> int:
return len(self.all_paths)
def remote_hostname(path: str) -> Tuple[Optional[str], Optional[str]]:
for q in remote_dirs:
if path.startswith(q):
return q, remote_dirs[q]
return None, None
def resolve_remote_name(path: str, default: str) -> str:
remote_dir, rh = remote_hostname(path)
if remote_dir and rh:
return f'{rh}:{os.path.relpath(path, remote_dir)}'
return default
def allowed_items(items: Sequence[str], ignore_patterns: Sequence[str]) -> Iterator[str]:
for name in items:
for pat in ignore_patterns:
if fnmatch(name, pat):
break
else:
yield name
def walk(base: str, names: Set[str], pmap: Dict[str, str], ignore_names: Tuple[str, ...]) -> None:
for dirpath, dirnames, filenames in os.walk(base):
dirnames[:] = allowed_items(dirnames, ignore_names)
for filename in allowed_items(filenames, ignore_names):
path = os.path.abspath(os.path.join(dirpath, filename))
path_name_map[path] = name = os.path.relpath(path, base)
names.add(name)
pmap[name] = path
def collect_files(collection: Collection, left: str, right: str) -> None:
left_names: Set[str] = set()
right_names: Set[str] = set()
left_path_map: Dict[str, str] = {}
right_path_map: Dict[str, str] = {}
walk(left, left_names, left_path_map, collection.ignore_names)
walk(right, right_names, right_path_map, collection.ignore_names)
common_names = left_names & right_names
changed_names = {n for n in common_names if data_for_path(left_path_map[n]) != data_for_path(right_path_map[n])}
for n in changed_names:
collection.add_change(left_path_map[n], right_path_map[n])
removed = left_names - common_names
added = right_names - common_names
ahash = {a: hash_for_path(right_path_map[a]) for a in added}
rhash = {r: hash_for_path(left_path_map[r]) for r in removed}
for name, rh in rhash.items():
for n, ah in ahash.items():
if ah == rh and data_for_path(left_path_map[name]) == data_for_path(right_path_map[n]):
collection.add_rename(left_path_map[name], right_path_map[n])
added.discard(n)
break
else:
collection.add_removal(left_path_map[name])
for name in added:
collection.add_add(right_path_map[name])
def sanitize(text: str) -> str:
ntext = text.replace('\r\n', '\n')
return control_codes_pat().sub('', ntext)
@lru_cache(maxsize=1024)
def mime_type_for_path(path: str) -> str:
return guess_type(path, allow_filesystem_access=True) or 'application/octet-stream'
@lru_cache(maxsize=1024)
def raw_data_for_path(path: str) -> bytes:
with open(path, 'rb') as f:
return f.read()
def is_image(path: Optional[str]) -> bool:
return mime_type_for_path(path).startswith('image/') if path else False
@lru_cache(maxsize=1024)
def data_for_path(path: str) -> Union[str, bytes]:
raw_bytes = raw_data_for_path(path)
if not is_image(path) and not os.path.samefile(path, os.devnull):
with suppress(UnicodeDecodeError):
return raw_bytes.decode('utf-8')
return raw_bytes
class LinesForPath:
replace_tab_by = ' ' * 4
@lru_cache(maxsize=1024)
def __call__(self, path: str) -> Tuple[str, ...]:
data = data_for_path(path)
assert isinstance(data, str)
data = data.replace('\t', self.replace_tab_by)
return tuple(sanitize(data).splitlines())
lines_for_path = LinesForPath()
@lru_cache(maxsize=1024)
def hash_for_path(path: str) -> bytes:
return md5(raw_data_for_path(path)).digest()
def create_collection(left: str, right: str) -> Collection:
collection = Collection()
if os.path.isdir(left):
collect_files(collection, left, right)
else:
pl, pr = os.path.abspath(left), os.path.abspath(right)
path_name_map[pl] = resolve_remote_name(pl, left)
path_name_map[pr] = resolve_remote_name(pr, right)
collection.add_change(pl, pr)
collection.finalize()
return collection
highlight_data: Dict[str, 'DiffHighlight'] = {}
def set_highlight_data(data: Dict[str, 'DiffHighlight']) -> None:
global highlight_data
highlight_data = data
def highlights_for_path(path: str) -> 'DiffHighlight':
return highlight_data.get(path, [])

View File

@ -1,70 +0,0 @@
#!/usr/bin/env python3
# License: GPL v3 Copyright: 2018, Kovid Goyal <kovid at kovidgoyal.net>
import os
from typing import Any, Dict, Iterable, Optional
from kitty.cli_stub import DiffCLIOptions
from kitty.conf.utils import load_config as _load_config
from kitty.conf.utils import parse_config_base, resolve_config
from kitty.constants import config_dir
from kitty.rgb import color_as_sgr
from .options.types import Options as DiffOptions
from .options.types import defaults
formats: Dict[str, str] = {
'title': '',
'margin': '',
'text': '',
}
def set_formats(opts: DiffOptions) -> None:
formats['text'] = '48' + color_as_sgr(opts.background)
formats['title'] = '38' + color_as_sgr(opts.title_fg) + ';48' + color_as_sgr(opts.title_bg) + ';1'
formats['margin'] = '38' + color_as_sgr(opts.margin_fg) + ';48' + color_as_sgr(opts.margin_bg)
formats['added_margin'] = '38' + color_as_sgr(opts.margin_fg) + ';48' + color_as_sgr(opts.added_margin_bg)
formats['removed_margin'] = '38' + color_as_sgr(opts.margin_fg) + ';48' + color_as_sgr(opts.removed_margin_bg)
formats['added'] = '48' + color_as_sgr(opts.added_bg)
formats['removed'] = '48' + color_as_sgr(opts.removed_bg)
formats['filler'] = '48' + color_as_sgr(opts.filler_bg)
formats['margin_filler'] = '48' + color_as_sgr(opts.margin_filler_bg or opts.filler_bg)
formats['hunk_margin'] = '38' + color_as_sgr(opts.margin_fg) + ';48' + color_as_sgr(opts.hunk_margin_bg)
formats['hunk'] = '38' + color_as_sgr(opts.margin_fg) + ';48' + color_as_sgr(opts.hunk_bg)
formats['removed_highlight'] = '48' + color_as_sgr(opts.highlight_removed_bg)
formats['added_highlight'] = '48' + color_as_sgr(opts.highlight_added_bg)
SYSTEM_CONF = '/etc/xdg/kitty/diff.conf'
defconf = os.path.join(config_dir, 'diff.conf')
def load_config(*paths: str, overrides: Optional[Iterable[str]] = None) -> DiffOptions:
from .options.parse import create_result_dict, merge_result_dicts, parse_conf_item
def parse_config(lines: Iterable[str]) -> Dict[str, Any]:
ans: Dict[str, Any] = create_result_dict()
parse_config_base(
lines,
parse_conf_item,
ans,
)
return ans
overrides = tuple(overrides) if overrides is not None else ()
opts_dict, paths = _load_config(defaults, parse_config, merge_result_dicts, *paths, overrides=overrides)
opts = DiffOptions(opts_dict)
opts.config_paths = paths
opts.config_overrides = overrides
return opts
def init_config(args: DiffCLIOptions) -> DiffOptions:
config = tuple(resolve_config(SYSTEM_CONF, defconf, args.config))
overrides = (a.replace('=', ' ', 1) for a in args.override or ())
opts = load_config(*config, overrides=overrides)
set_formats(opts)
for (sc, action) in opts.map:
opts.key_definitions[sc] = action
return opts

View File

@ -1,16 +0,0 @@
from typing import Callable, List, Optional, Tuple
from .collect import Segment
def splitlines_like_git(raw: bytes, callback: Callable[[memoryview], None]) -> None: ...
def split_with_highlights(
line: str, truncate_points: List[int], fg_highlights: List[Segment],
bg_highlight: Optional[Segment]
) -> List[str]:
pass
def changed_center(left_prefix: str, right_postfix: str) -> Tuple[int, int]:
pass

View File

@ -1,185 +0,0 @@
#!/usr/bin/env python3
# License: GPL v3 Copyright: 2018, Kovid Goyal <kovid at kovidgoyal.net>
import concurrent
import os
import re
from concurrent.futures import ProcessPoolExecutor
from typing import IO, Dict, Iterable, Iterator, List, Optional, Tuple, Union, cast
from pygments import highlight # type: ignore
from pygments.formatter import Formatter # type: ignore
from pygments.lexers import get_lexer_for_filename # type: ignore
from pygments.util import ClassNotFound # type: ignore
from kitty.multiprocessing import get_process_pool_executor
from kitty.rgb import color_as_sgr, parse_sharp
from .collect import Collection, Segment, data_for_path, lines_for_path
class StyleNotFound(Exception):
pass
class DiffFormatter(Formatter): # type: ignore
def __init__(self, style: str = 'default') -> None:
try:
Formatter.__init__(self, style=style)
initialized = True
except ClassNotFound:
initialized = False
if not initialized:
raise StyleNotFound(f'pygments style "{style}" not found')
self.styles: Dict[str, Tuple[str, str]] = {}
for token, token_style in self.style:
start = []
end = []
fstart = fend = ''
# a style item is a tuple in the following form:
# colors are readily specified in hex: 'RRGGBB'
col = token_style['color']
if col:
pc = parse_sharp(col)
if pc is not None:
start.append('38' + color_as_sgr(pc))
end.append('39')
if token_style['bold']:
start.append('1')
end.append('22')
if token_style['italic']:
start.append('3')
end.append('23')
if token_style['underline']:
start.append('4')
end.append('24')
if start:
fstart = '\033[{}m'.format(';'.join(start))
fend = '\033[{}m'.format(';'.join(end))
self.styles[token] = fstart, fend
def format(self, tokensource: Iterable[Tuple[str, str]], outfile: IO[str]) -> None:
for ttype, value in tokensource:
not_found = True
if value.rstrip('\n'):
while ttype and not_found:
tok = self.styles.get(ttype)
if tok is None:
ttype = ttype[:-1]
else:
on, off = tok
lines = value.split('\n')
for line in lines:
if line:
outfile.write(on + line + off)
if line is not lines[-1]:
outfile.write('\n')
not_found = False
if not_found:
outfile.write(value)
formatter: Optional[DiffFormatter] = None
def initialize_highlighter(style: str = 'default') -> None:
global formatter
formatter = DiffFormatter(style)
def highlight_data(code: str, filename: str, aliases: Optional[Dict[str, str]] = None) -> Optional[str]:
if aliases:
base, ext = os.path.splitext(filename)
alias = aliases.get(ext[1:])
if alias is not None:
filename = f'{base}.{alias}'
try:
lexer = get_lexer_for_filename(filename, stripnl=False)
except ClassNotFound:
return None
return cast(str, highlight(code, lexer, formatter))
split_pat = re.compile(r'(\033\[.*?m)')
def highlight_line(line: str) -> List[Segment]:
ans: List[Segment] = []
current: Optional[Segment] = None
pos = 0
for x in split_pat.split(line):
if x.startswith('\033'):
if current is None:
current = Segment(pos, x)
else:
current.end = pos
current.end_code = x
ans.append(current)
current = None
else:
pos += len(x)
return ans
DiffHighlight = List[List[Segment]]
def highlight_for_diff(path: str, aliases: Dict[str, str]) -> DiffHighlight:
ans: DiffHighlight = []
lines = lines_for_path(path)
hd = highlight_data('\n'.join(lines), path, aliases)
if hd is not None:
for line in hd.splitlines():
ans.append(highlight_line(line))
return ans
process_pool_executor: Optional[ProcessPoolExecutor] = None
def get_highlight_processes() -> Iterator[int]:
if process_pool_executor is None:
return
for pid in process_pool_executor._processes:
yield pid
def highlight_collection(collection: Collection, aliases: Optional[Dict[str, str]] = None) -> Union[str, Dict[str, DiffHighlight]]:
global process_pool_executor
jobs = {}
ans: Dict[str, DiffHighlight] = {}
with get_process_pool_executor(prefer_fork=True) as executor:
process_pool_executor = executor
for path, item_type, other_path in collection:
if item_type != 'rename':
for p in (path, other_path):
if p:
is_binary = isinstance(data_for_path(p), bytes)
if not is_binary:
jobs[executor.submit(highlight_for_diff, p, aliases or {})] = p
for future in concurrent.futures.as_completed(jobs):
path = jobs[future]
try:
highlights = future.result()
except Exception as e:
import traceback
tb = traceback.format_exc()
return f'Running syntax highlighting for {path} generated an exception: {e} with traceback:\n{tb}'
ans[path] = highlights
return ans
def main() -> None:
# kitty +runpy "from kittens.diff.highlight import main; main()" file
import sys
from .options.types import defaults
initialize_highlighter()
with open(sys.argv[-1]) as f:
highlighted = highlight_data(f.read(), f.name, defaults.syntax_aliases)
if highlighted is None:
raise SystemExit(f'Unknown filetype: {sys.argv[-1]}')
print(highlighted)

View File

@ -1,591 +1,272 @@
#!/usr/bin/env python3
# License: GPL v3 Copyright: 2018, Kovid Goyal <kovid at kovidgoyal.net>
import atexit
import os
import signal
import subprocess
import sys
import tempfile
import warnings
from collections import defaultdict
from contextlib import suppress
from enum import Enum, auto
from functools import partial
from gettext import gettext as _
from typing import (
Any,
DefaultDict,
Dict,
Iterable,
Iterator,
List,
Optional,
Tuple,
Union,
)
from typing import List
from kitty.cli import CONFIG_HELP, CompletionSpec, parse_args
from kitty.cli_stub import DiffCLIOptions
from kitty.conf.utils import KeyAction
from kitty.cli import CONFIG_HELP, CompletionSpec
from kitty.conf.types import Definition
from kitty.constants import appname
from kitty.fast_data_types import wcswidth
from kitty.key_encoding import EventType, KeyEvent
from kitty.utils import ScreenSize, extract_all_from_tarfile_safely
from ..tui.handler import Handler
from ..tui.images import ImageManager, Placement
from ..tui.line_edit import LineEdit
from ..tui.loop import Loop
from ..tui.operations import styled
from . import global_data
from .collect import (
Collection,
add_remote_dir,
create_collection,
data_for_path,
lines_for_path,
sanitize,
set_highlight_data,
def main(args: List[str]) -> None:
raise SystemExit('Must be run as kitten diff')
definition = Definition(
'!kittens.diff',
)
from .config import init_config
from .options.types import Options as DiffOptions
from .patch import Differ, Patch, set_diff_command, worker_processes
from .render import (
ImagePlacement,
ImageSupportWarning,
Line,
LineRef,
Reference,
render_diff,
agr = definition.add_group
egr = definition.end_group
opt = definition.add_option
map = definition.add_map
mma = definition.add_mouse_map
# diff {{{
agr('diff', 'Diffing')
opt('syntax_aliases', 'pyj:py pyi:py recipe:py', ctype='strdict_ _:', option_type='syntax_aliases',
long_text='''
File extension aliases for syntax highlight. For example, to syntax highlight
:file:`file.xyz` as :file:`file.abc` use a setting of :code:`xyz:abc`.
Multiple aliases must be separated by spaces.
'''
)
from .search import BadRegex, Search
try:
from .highlight import (
DiffHighlight,
get_highlight_processes,
highlight_collection,
initialize_highlighter,
opt('num_context_lines', '3', option_type='positive_int',
long_text='The number of lines of context to show around each change.'
)
has_highlighter = True
DiffHighlight
except ImportError:
has_highlighter = False
def highlight_collection(collection: 'Collection', aliases: Optional[Dict[str, str]] = None) -> Union[str, Dict[str, 'DiffHighlight']]:
return ''
def get_highlight_processes() -> Iterator[int]:
if has_highlighter:
yield -1
class State(Enum):
initializing = auto()
collected = auto()
diffed = auto()
command = auto()
message = auto()
class BackgroundWork(Enum):
none = auto()
collecting = auto()
diffing = auto()
highlighting = auto()
def generate_diff(collection: Collection, context: int) -> Union[str, Dict[str, Patch]]:
d = Differ()
for path, item_type, changed_path in collection:
if item_type == 'diff':
is_binary = isinstance(data_for_path(path), bytes) or isinstance(data_for_path(changed_path), bytes)
if not is_binary:
assert changed_path is not None
d.add_diff(path, changed_path)
return d(context)
class DiffHandler(Handler):
image_manager_class = ImageManager
def __init__(self, args: DiffCLIOptions, opts: DiffOptions, left: str, right: str) -> None:
self.state = State.initializing
self.message = ''
self.current_search_is_regex = True
self.current_search: Optional[Search] = None
self.line_edit = LineEdit()
self.opts = opts
self.left, self.right = left, right
self.report_traceback_on_exit: Union[str, Dict[str, Patch], None] = None
self.args = args
self.scroll_pos = self.max_scroll_pos = 0
self.current_context_count = self.original_context_count = self.args.context
if self.current_context_count < 0:
self.current_context_count = self.original_context_count = self.opts.num_context_lines
self.highlighting_done = False
self.doing_background_work = BackgroundWork.none
self.restore_position: Optional[Reference] = None
for key_def, action in self.opts.key_definitions.items():
self.add_shortcut(action, key_def)
def terminate(self, return_code: int = 0) -> None:
self.quit_loop(return_code)
def perform_action(self, action: KeyAction) -> None:
func, args = action
if func == 'quit':
self.terminate()
return
if self.state.value <= State.diffed.value:
if func == 'scroll_by':
return self.scroll_lines(int(args[0] or 0))
if func == 'scroll_to':
where = str(args[0])
if 'change' in where:
return self.scroll_to_next_change(backwards='prev' in where)
if 'match' in where:
return self.scroll_to_next_match(backwards='prev' in where)
if 'page' in where:
amt = self.num_lines * (1 if 'next' in where else -1)
else:
amt = len(self.diff_lines) * (1 if 'end' in where else -1)
return self.scroll_lines(amt)
if func == 'change_context':
new_ctx = self.current_context_count
to = args[0]
if to == 'all':
new_ctx = 100000
elif to == 'default':
new_ctx = self.original_context_count
else:
new_ctx += int(to or 0)
return self.change_context_count(new_ctx)
if func == 'start_search':
self.start_search(bool(args[0]), bool(args[1]))
return
def create_collection(self) -> None:
def collect_done(collection: Collection) -> None:
self.doing_background_work = BackgroundWork.none
self.collection = collection
self.state = State.collected
self.generate_diff()
def collect(left: str, right: str) -> None:
collection = create_collection(left, right)
self.asyncio_loop.call_soon_threadsafe(collect_done, collection)
self.asyncio_loop.run_in_executor(None, collect, self.left, self.right)
self.doing_background_work = BackgroundWork.collecting
def generate_diff(self) -> None:
def diff_done(diff_map: Union[str, Dict[str, Patch]]) -> None:
self.doing_background_work = BackgroundWork.none
if isinstance(diff_map, str):
self.report_traceback_on_exit = diff_map
self.terminate(1)
return
self.state = State.diffed
self.diff_map = diff_map
self.calculate_statistics()
self.render_diff()
self.scroll_pos = 0
if self.restore_position is not None:
self.current_position = self.restore_position
self.restore_position = None
self.draw_screen()
if has_highlighter and not self.highlighting_done:
from .highlight import StyleNotFound
self.highlighting_done = True
try:
initialize_highlighter(self.opts.pygments_style)
except StyleNotFound as e:
self.report_traceback_on_exit = str(e)
self.terminate(1)
return
self.syntax_highlight()
def diff(collection: Collection, current_context_count: int) -> None:
diff_map = generate_diff(collection, current_context_count)
self.asyncio_loop.call_soon_threadsafe(diff_done, diff_map)
self.asyncio_loop.run_in_executor(None, diff, self.collection, self.current_context_count)
self.doing_background_work = BackgroundWork.diffing
def syntax_highlight(self) -> None:
def highlighting_done(hdata: Union[str, Dict[str, 'DiffHighlight']]) -> None:
self.doing_background_work = BackgroundWork.none
if isinstance(hdata, str):
self.report_traceback_on_exit = hdata
self.terminate(1)
return
set_highlight_data(hdata)
self.render_diff()
self.draw_screen()
def highlight(collection: Collection, aliases: Optional[Dict[str, str]] = None) -> None:
result = highlight_collection(collection, aliases)
self.asyncio_loop.call_soon_threadsafe(highlighting_done, result)
self.asyncio_loop.run_in_executor(None, highlight, self.collection, self.opts.syntax_aliases)
self.doing_background_work = BackgroundWork.highlighting
def calculate_statistics(self) -> None:
self.added_count = self.collection.added_count
self.removed_count = self.collection.removed_count
for patch in self.diff_map.values():
self.added_count += patch.added_count
self.removed_count += patch.removed_count
def render_diff(self) -> None:
self.diff_lines: Tuple[Line, ...] = tuple(render_diff(self.collection, self.diff_map, self.args, self.screen_size.cols, self.image_manager))
self.margin_size = render_diff.margin_size
self.ref_path_map: DefaultDict[str, List[Tuple[int, Reference]]] = defaultdict(list)
for i, dl in enumerate(self.diff_lines):
self.ref_path_map[dl.ref.path].append((i, dl.ref))
self.max_scroll_pos = len(self.diff_lines) - self.num_lines
if self.current_search is not None:
self.current_search(self.diff_lines, self.margin_size, self.screen_size.cols)
@property
def current_position(self) -> Reference:
return self.diff_lines[min(len(self.diff_lines) - 1, self.scroll_pos)].ref
@current_position.setter
def current_position(self, ref: Reference) -> None:
num = None
if isinstance(ref.extra, LineRef):
sln = ref.extra.src_line_number
for i, q in self.ref_path_map[ref.path]:
if isinstance(q.extra, LineRef):
if q.extra.src_line_number >= sln:
if q.extra.src_line_number == sln:
num = i
break
num = i
if num is None:
for i, q in self.ref_path_map[ref.path]:
num = i
break
if num is not None:
self.scroll_pos = max(0, min(num, self.max_scroll_pos))
@property
def num_lines(self) -> int:
return self.screen_size.rows - 1
def scroll_to_next_change(self, backwards: bool = False) -> None:
if backwards:
r = range(self.scroll_pos - 1, -1, -1)
else:
r = range(self.scroll_pos + 1, len(self.diff_lines))
for i in r:
line = self.diff_lines[i]
if line.is_change_start:
self.scroll_lines(i - self.scroll_pos)
return
self.cmd.bell()
def scroll_to_next_match(self, backwards: bool = False, include_current: bool = False) -> None:
if self.current_search is not None:
offset = 0 if include_current else 1
if backwards:
r = range(self.scroll_pos - offset, -1, -1)
else:
r = range(self.scroll_pos + offset, len(self.diff_lines))
for i in r:
if i in self.current_search:
self.scroll_lines(i - self.scroll_pos)
return
self.cmd.bell()
def set_scrolling_region(self) -> None:
self.cmd.set_scrolling_region(self.screen_size, 0, self.num_lines - 2)
def scroll_lines(self, amt: int = 1) -> None:
new_pos = max(0, min(self.scroll_pos + amt, self.max_scroll_pos))
amt = new_pos - self.scroll_pos
if new_pos == self.scroll_pos:
self.cmd.bell()
return
if abs(amt) >= self.num_lines - 1:
self.scroll_pos = new_pos
self.draw_screen()
return
self.enforce_cursor_state()
self.cmd.scroll_screen(amt)
self.scroll_pos = new_pos
if amt < 0:
self.cmd.set_cursor_position(0, 0)
self.draw_lines(-amt)
else:
self.cmd.set_cursor_position(0, self.num_lines - amt)
self.draw_lines(amt, self.num_lines - amt)
self.draw_status_line()
def init_terminal_state(self) -> None:
self.cmd.set_line_wrapping(False)
self.cmd.set_window_title(global_data.title)
self.cmd.set_default_colors(
fg=self.opts.foreground, bg=self.opts.background,
cursor=self.opts.foreground, select_fg=self.opts.select_fg,
select_bg=self.opts.select_bg)
self.cmd.set_cursor_shape('beam')
def finalize(self) -> None:
self.cmd.set_default_colors()
self.cmd.set_cursor_visible(True)
self.cmd.set_scrolling_region()
def initialize(self) -> None:
self.init_terminal_state()
self.set_scrolling_region()
self.draw_screen()
self.create_collection()
def enforce_cursor_state(self) -> None:
self.cmd.set_cursor_visible(self.state is State.command)
def draw_lines(self, num: int, offset: int = 0) -> None:
offset += self.scroll_pos
image_involved = False
limit = len(self.diff_lines)
for i in range(num):
lpos = offset + i
if lpos >= limit:
text = ''
else:
line = self.diff_lines[lpos]
text = line.text
if line.image_data is not None:
image_involved = True
self.write(f'\r\x1b[K{text}\x1b[0m')
if self.current_search is not None:
self.current_search.highlight_line(self.write, lpos)
if i < num - 1:
self.write('\n')
if image_involved:
self.place_images()
def update_image_placement_for_resend(self, image_id: int, pl: Placement) -> bool:
offset = self.scroll_pos
limit = len(self.diff_lines)
in_image = False
def adjust(row: int, candidate: ImagePlacement, is_left: bool) -> bool:
if candidate.image.image_id == image_id:
q = self.xpos_for_image(row, candidate, is_left)
if q is not None:
pl.x = q[0]
pl.y = row
return True
return False
for row in range(self.num_lines):
lpos = offset + row
if lpos >= limit:
break
line = self.diff_lines[lpos]
if in_image:
if line.image_data is None:
in_image = False
continue
if line.image_data is not None:
left_placement, right_placement = line.image_data
if left_placement is not None:
if adjust(row, left_placement, True):
return True
in_image = True
if right_placement is not None:
if adjust(row, right_placement, False):
return True
in_image = True
return False
def place_images(self) -> None:
self.image_manager.update_image_placement_for_resend = self.update_image_placement_for_resend
self.cmd.clear_images_on_screen()
offset = self.scroll_pos
limit = len(self.diff_lines)
in_image = False
for row in range(self.num_lines):
lpos = offset + row
if lpos >= limit:
break
line = self.diff_lines[lpos]
if in_image:
if line.image_data is None:
in_image = False
continue
if line.image_data is not None:
left_placement, right_placement = line.image_data
if left_placement is not None:
self.place_image(row, left_placement, True)
in_image = True
if right_placement is not None:
self.place_image(row, right_placement, False)
in_image = True
def xpos_for_image(self, row: int, placement: ImagePlacement, is_left: bool) -> Optional[Tuple[int, float]]:
xpos = (0 if is_left else (self.screen_size.cols // 2)) + placement.image.margin_size
image_height_in_rows = placement.image.rows
topmost_visible_row = placement.row
num_visible_rows = image_height_in_rows - topmost_visible_row
visible_frac = min(num_visible_rows / image_height_in_rows, 1)
if visible_frac <= 0:
return None
return xpos, visible_frac
def place_image(self, row: int, placement: ImagePlacement, is_left: bool) -> None:
q = self.xpos_for_image(row, placement, is_left)
if q is not None:
xpos, visible_frac = q
height = int(visible_frac * placement.image.height)
top = placement.image.height - height
self.image_manager.show_image(placement.image.image_id, xpos, row, src_rect=(
0, top, placement.image.width, height))
def draw_screen(self) -> None:
self.enforce_cursor_state()
if self.state.value < State.diffed.value:
self.cmd.clear_screen()
self.write(_('Calculating diff, please wait...'))
return
self.cmd.clear_images_on_screen()
self.cmd.set_cursor_position(0, 0)
self.draw_lines(self.num_lines)
self.draw_status_line()
def draw_status_line(self) -> None:
if self.state.value < State.diffed.value:
return
self.enforce_cursor_state()
self.cmd.set_cursor_position(0, self.num_lines)
self.cmd.clear_to_eol()
if self.state is State.command:
self.line_edit.write(self.write)
elif self.state is State.message:
self.cmd.styled(self.message, reverse=True)
else:
sp = f'{self.scroll_pos/self.max_scroll_pos:.0%}' if self.scroll_pos and self.max_scroll_pos else '0%'
scroll_frac = styled(sp, fg=self.opts.margin_fg)
if self.current_search is None:
counts = '{}{}{}'.format(
styled(str(self.added_count), fg=self.opts.highlight_added_bg),
styled(',', fg=self.opts.margin_fg),
styled(str(self.removed_count), fg=self.opts.highlight_removed_bg)
opt('diff_cmd', 'auto',
long_text='''
The diff command to use. Must contain the placeholder :code:`_CONTEXT_` which
will be replaced by the number of lines of context. A few special values are allowed:
:code:`auto` will automatically pick an available diff implementation. :code:`builtin`
will use the anchored diff algorithm from the Go standard library. :code:`git` will
use the git command to do the diffing. :code:`diff` will use the diff command to
do the diffing.
'''
)
else:
counts = styled(f'{len(self.current_search)} matches', fg=self.opts.margin_fg)
suffix = f'{counts} {scroll_frac}'
prefix = styled(':', fg=self.opts.margin_fg)
filler = self.screen_size.cols - wcswidth(prefix) - wcswidth(suffix)
text = '{}{}{}'.format(prefix, ' ' * filler, suffix)
self.write(text)
def change_context_count(self, new_ctx: int) -> None:
new_ctx = max(0, new_ctx)
if new_ctx != self.current_context_count:
self.current_context_count = new_ctx
self.state = State.collected
self.generate_diff()
self.restore_position = self.current_position
self.draw_screen()
opt('replace_tab_by', '\\x20\\x20\\x20\\x20', option_type='python_string',
long_text='The string to replace tabs with. Default is to use four spaces.'
)
def start_search(self, is_regex: bool, is_backward: bool) -> None:
if self.state is not State.diffed:
self.cmd.bell()
return
self.state = State.command
self.line_edit.clear()
self.line_edit.add_text('?' if is_backward else '/')
self.current_search_is_regex = is_regex
self.draw_status_line()
opt('+ignore_name', '', ctype='string',
add_to_default=False,
long_text='''
A glob pattern that is matched against only the filename of files and directories. Matching
files and directories are ignored when scanning the filesystem to look for files to diff.
Can be specified multiple times to use multiple patterns. For example::
def do_search(self) -> None:
self.current_search = None
query = self.line_edit.current_input
if len(query) < 2:
return
try:
self.current_search = Search(self.opts, query[1:], self.current_search_is_regex, query[0] == '?')
except BadRegex:
self.state = State.message
self.message = sanitize(_('Bad regex: {}').format(query[1:]))
self.cmd.bell()
else:
if self.current_search(self.diff_lines, self.margin_size, self.screen_size.cols):
self.scroll_to_next_match(include_current=True)
else:
self.state = State.message
self.message = sanitize(_('No matches found'))
self.cmd.bell()
ignore_name .git
ignore_name *~
ignore_name *.pyc
''',
)
def on_key_event(self, key_event: KeyEvent, in_bracketed_paste: bool = False) -> None:
if key_event.text:
if self.state is State.command:
self.line_edit.on_text(key_event.text, in_bracketed_paste)
self.draw_status_line()
return
if self.state is State.message:
self.state = State.diffed
self.draw_status_line()
return
else:
if self.state is State.message:
if key_event.type is not EventType.RELEASE:
self.state = State.diffed
self.draw_status_line()
return
if self.state is State.command:
if self.line_edit.on_key(key_event):
if not self.line_edit.current_input:
self.state = State.diffed
self.draw_status_line()
return
if key_event.matches('enter'):
self.state = State.diffed
self.do_search()
self.line_edit.clear()
self.draw_screen()
return
if key_event.matches('esc'):
self.state = State.diffed
self.draw_status_line()
return
if self.state.value >= State.diffed.value and self.current_search is not None and key_event.matches('esc'):
self.current_search = None
self.draw_screen()
return
if key_event.type is EventType.RELEASE:
return
action = self.shortcut_action(key_event)
if action is not None:
return self.perform_action(action)
egr() # }}}
def on_resize(self, screen_size: ScreenSize) -> None:
self.screen_size = screen_size
self.set_scrolling_region()
if self.state.value > State.collected.value:
self.image_manager.delete_all_sent_images()
self.render_diff()
self.draw_screen()
# colors {{{
agr('colors', 'Colors')
def on_interrupt(self) -> None:
self.terminate(1)
opt('pygments_style', 'default',
long_text='''
The pygments color scheme to use for syntax highlighting. See :link:`pygments
builtin styles <https://pygments.org/styles/>` for a list of schemes. Note that
this **does not** change the colors used for diffing,
only the colors used for syntax highlighting. To change the general colors use the settings below.
'''
)
def on_eot(self) -> None:
self.terminate(1)
opt('foreground', 'black',
option_type='to_color',
long_text='Basic colors'
)
opt('background', 'white',
option_type='to_color',
)
opt('title_fg', 'black',
option_type='to_color',
long_text='Title colors'
)
opt('title_bg', 'white',
option_type='to_color',
)
opt('margin_bg', '#fafbfc',
option_type='to_color',
long_text='Margin colors'
)
opt('margin_fg', '#aaaaaa',
option_type='to_color',
)
opt('removed_bg', '#ffeef0',
option_type='to_color',
long_text='Removed text backgrounds'
)
opt('highlight_removed_bg', '#fdb8c0',
option_type='to_color',
)
opt('removed_margin_bg', '#ffdce0',
option_type='to_color',
)
opt('added_bg', '#e6ffed',
option_type='to_color',
long_text='Added text backgrounds'
)
opt('highlight_added_bg', '#acf2bd',
option_type='to_color',
)
opt('added_margin_bg', '#cdffd8',
option_type='to_color',
)
opt('filler_bg', '#fafbfc',
option_type='to_color',
long_text='Filler (empty) line background'
)
opt('margin_filler_bg', 'none',
option_type='to_color_or_none',
long_text='Filler (empty) line background in margins, defaults to the filler background'
)
opt('hunk_margin_bg', '#dbedff',
option_type='to_color',
long_text='Hunk header colors'
)
opt('hunk_bg', '#f1f8ff',
option_type='to_color',
)
opt('search_bg', '#444',
option_type='to_color',
long_text='Highlighting'
)
opt('search_fg', 'white',
option_type='to_color',
)
opt('select_bg', '#b4d5fe',
option_type='to_color',
)
opt('select_fg', 'black',
option_type='to_color_or_none',
)
egr() # }}}
# shortcuts {{{
agr('shortcuts', 'Keyboard shortcuts')
map('Quit',
'quit q quit',
)
map('Quit',
'quit esc quit',
)
map('Scroll down',
'scroll_down j scroll_by 1',
)
map('Scroll down',
'scroll_down down scroll_by 1',
)
map('Scroll up',
'scroll_up k scroll_by -1',
)
map('Scroll up',
'scroll_up up scroll_by -1',
)
map('Scroll to top',
'scroll_top home scroll_to start',
)
map('Scroll to bottom',
'scroll_bottom end scroll_to end',
)
map('Scroll to next page',
'scroll_page_down page_down scroll_to next-page',
)
map('Scroll to next page',
'scroll_page_down space scroll_to next-page',
)
map('Scroll to previous page',
'scroll_page_up page_up scroll_to prev-page',
)
map('Scroll to next change',
'next_change n scroll_to next-change',
)
map('Scroll to previous change',
'prev_change p scroll_to prev-change',
)
map('Show all context',
'all_context a change_context all',
)
map('Show default context',
'default_context = change_context default',
)
map('Increase context',
'increase_context + change_context 5',
)
map('Decrease context',
'decrease_context - change_context -5',
)
map('Search forward',
'search_forward / start_search regex forward',
)
map('Search backward',
'search_backward ? start_search regex backward',
)
map('Scroll to next search match',
'next_match . scroll_to next-match',
)
map('Scroll to next search match',
'next_match > scroll_to next-match',
)
map('Scroll to previous search match',
'prev_match , scroll_to prev-match',
)
map('Scroll to previous search match',
'prev_match < scroll_to prev-match',
)
map('Search forward (no regex)',
'search_forward_simple f start_search substring forward',
)
map('Search backward (no regex)',
'search_backward_simple b start_search substring backward',
)
egr() # }}}
OPTIONS = partial('''\
--context
@ -607,99 +288,10 @@ Override individual configuration options, can be specified multiple times.
Syntax: :italic:`name=value`. For example: :italic:`-o background=gray`
'''.format, config_help=CONFIG_HELP.format(conf_name='diff', appname=appname))
class ShowWarning:
def __init__(self) -> None:
self.warnings: List[str] = []
def __call__(self, message: Any, category: Any, filename: str, lineno: int, file: object = None, line: object = None) -> None:
if category is ImageSupportWarning and isinstance(message, str):
showwarning.warnings.append(message)
showwarning = ShowWarning()
help_text = 'Show a side-by-side diff of the specified files/directories. You can also use :italic:`ssh:hostname:remote-file-path` to diff remote files.'
usage = 'file_or_directory_left file_or_directory_right'
def terminate_processes(processes: Iterable[int]) -> None:
for pid in processes:
with suppress(Exception):
os.kill(pid, signal.SIGKILL)
def get_ssh_file(hostname: str, rpath: str) -> str:
import io
import shutil
import tarfile
tdir = tempfile.mkdtemp(suffix=f'-{hostname}')
add_remote_dir(tdir)
atexit.register(shutil.rmtree, tdir)
is_abs = rpath.startswith('/')
rpath = rpath.lstrip('/')
cmd = ['ssh', hostname, 'tar', '-c', '-f', '-']
if is_abs:
cmd.extend(('-C', '/'))
cmd.append(rpath)
p = subprocess.Popen(cmd, stdout=subprocess.PIPE)
assert p.stdout is not None
raw = p.stdout.read()
if p.wait() != 0:
raise SystemExit(p.returncode)
with tarfile.open(fileobj=io.BytesIO(raw), mode='r:') as tf:
members = tf.getmembers()
extract_all_from_tarfile_safely(tf, tdir)
if len(members) == 1:
for root, dirs, files in os.walk(tdir):
if files:
return os.path.join(root, files[0])
return os.path.abspath(os.path.join(tdir, rpath))
def get_remote_file(path: str) -> str:
if path.startswith('ssh:'):
parts = path.split(':', 2)
if len(parts) == 3:
return get_ssh_file(parts[1], parts[2])
return path
def main(args: List[str]) -> None:
warnings.showwarning = showwarning
cli_opts, items = parse_args(args[1:], OPTIONS, usage, help_text, 'kitty +kitten diff', result_class=DiffCLIOptions)
if len(items) != 2:
raise SystemExit('You must specify exactly two files/directories to compare')
left, right = items
global_data.title = _('{} vs. {}').format(left, right)
opts = init_config(cli_opts)
set_diff_command(opts.diff_cmd)
lines_for_path.replace_tab_by = opts.replace_tab_by
Collection.ignore_names = tuple(opts.ignore_name)
left, right = map(get_remote_file, (left, right))
if os.path.isdir(left) != os.path.isdir(right):
raise SystemExit('The items to be diffed should both be either directories or files. Comparing a directory to a file is not valid.')
for f in left, right:
if not os.path.exists(f):
raise SystemExit(f'{f} does not exist')
loop = Loop()
handler = DiffHandler(cli_opts, opts, left, right)
loop.loop(handler)
for message in showwarning.warnings:
from kitty.utils import safe_print
safe_print(message, file=sys.stderr)
if handler.doing_background_work is BackgroundWork.highlighting:
terminate_processes(tuple(get_highlight_processes()))
elif handler.doing_background_work == BackgroundWork.diffing:
terminate_processes(tuple(worker_processes))
if loop.return_code != 0:
if handler.report_traceback_on_exit:
print(handler.report_traceback_on_exit, file=sys.stderr)
input('Press Enter to quit.')
raise SystemExit(loop.return_code)
if __name__ == '__main__':
main(sys.argv)
@ -711,5 +303,4 @@ elif __name__ == '__doc__':
cd['short_desc'] = 'Pretty, side-by-side diffing of files and images'
cd['args_completion'] = CompletionSpec.from_string('type:file mime:text/* mime:image/* group:"Text and image files"')
elif __name__ == '__conf__':
from .options.definition import definition
sys.options_definition = definition # type: ignore

View File

@ -1,267 +0,0 @@
#!/usr/bin/env python
# vim:fileencoding=utf-8
# License: GPLv3 Copyright: 2021, Kovid Goyal <kovid at kovidgoyal.net>
# After editing this file run ./gen-config.py to apply the changes
from kitty.conf.types import Action, Definition
definition = Definition(
'kittens.diff',
Action('map', 'parse_map', {'key_definitions': 'kitty.conf.utils.KittensKeyMap'}, ['kitty.types.ParsedShortcut', 'kitty.conf.utils.KeyAction']),
)
agr = definition.add_group
egr = definition.end_group
opt = definition.add_option
map = definition.add_map
mma = definition.add_mouse_map
# diff {{{
agr('diff', 'Diffing')
opt('syntax_aliases', 'pyj:py pyi:py recipe:py',
option_type='syntax_aliases', ctype='strdict_ _:',
long_text='''
File extension aliases for syntax highlight. For example, to syntax highlight
:file:`file.xyz` as :file:`file.abc` use a setting of :code:`xyz:abc`.
Multiple aliases must be separated by spaces.
'''
)
opt('num_context_lines', '3',
option_type='positive_int',
long_text='The number of lines of context to show around each change.'
)
opt('diff_cmd', 'auto',
long_text='''
The diff command to use. Must contain the placeholder :code:`_CONTEXT_` which
will be replaced by the number of lines of context. A few special values are allowed:
:code:`auto` will automatically pick an available diff implementation. :code:`builtin`
will use the anchored diff algorithm from the Go standard library. :code:`git` will
use the git command to do the diffing. :code:`diff` will use the diff command to
do the diffing.
'''
)
opt('replace_tab_by', '\\x20\\x20\\x20\\x20',
option_type='python_string',
long_text='The string to replace tabs with. Default is to use four spaces.'
)
opt('+ignore_name', '', ctype='string',
option_type='store_multiple',
add_to_default=False,
long_text='''
A glob pattern that is matched against only the filename of files and directories. Matching
files and directories are ignored when scanning the filesystem to look for files to diff.
Can be specified multiple times to use multiple patterns. For example::
ignore_name .git
ignore_name *~
ignore_name *.pyc
''',
)
egr() # }}}
# colors {{{
agr('colors', 'Colors')
opt('pygments_style', 'default',
long_text='''
The pygments color scheme to use for syntax highlighting. See :link:`pygments
builtin styles <https://pygments.org/styles/>` for a list of schemes. Note that
this **does not** change the colors used for diffing,
only the colors used for syntax highlighting. To change the general colors use the settings below.
'''
)
opt('foreground', 'black',
option_type='to_color',
long_text='Basic colors'
)
opt('background', 'white',
option_type='to_color',
)
opt('title_fg', 'black',
option_type='to_color',
long_text='Title colors'
)
opt('title_bg', 'white',
option_type='to_color',
)
opt('margin_bg', '#fafbfc',
option_type='to_color',
long_text='Margin colors'
)
opt('margin_fg', '#aaaaaa',
option_type='to_color',
)
opt('removed_bg', '#ffeef0',
option_type='to_color',
long_text='Removed text backgrounds'
)
opt('highlight_removed_bg', '#fdb8c0',
option_type='to_color',
)
opt('removed_margin_bg', '#ffdce0',
option_type='to_color',
)
opt('added_bg', '#e6ffed',
option_type='to_color',
long_text='Added text backgrounds'
)
opt('highlight_added_bg', '#acf2bd',
option_type='to_color',
)
opt('added_margin_bg', '#cdffd8',
option_type='to_color',
)
opt('filler_bg', '#fafbfc',
option_type='to_color',
long_text='Filler (empty) line background'
)
opt('margin_filler_bg', 'none',
option_type='to_color_or_none',
long_text='Filler (empty) line background in margins, defaults to the filler background'
)
opt('hunk_margin_bg', '#dbedff',
option_type='to_color',
long_text='Hunk header colors'
)
opt('hunk_bg', '#f1f8ff',
option_type='to_color',
)
opt('search_bg', '#444',
option_type='to_color',
long_text='Highlighting'
)
opt('search_fg', 'white',
option_type='to_color',
)
opt('select_bg', '#b4d5fe',
option_type='to_color',
)
opt('select_fg', 'black',
option_type='to_color_or_none',
)
egr() # }}}
# shortcuts {{{
agr('shortcuts', 'Keyboard shortcuts')
map('Quit',
'quit q quit',
)
map('Quit',
'quit esc quit',
)
map('Scroll down',
'scroll_down j scroll_by 1',
)
map('Scroll down',
'scroll_down down scroll_by 1',
)
map('Scroll up',
'scroll_up k scroll_by -1',
)
map('Scroll up',
'scroll_up up scroll_by -1',
)
map('Scroll to top',
'scroll_top home scroll_to start',
)
map('Scroll to bottom',
'scroll_bottom end scroll_to end',
)
map('Scroll to next page',
'scroll_page_down page_down scroll_to next-page',
)
map('Scroll to next page',
'scroll_page_down space scroll_to next-page',
)
map('Scroll to previous page',
'scroll_page_up page_up scroll_to prev-page',
)
map('Scroll to next change',
'next_change n scroll_to next-change',
)
map('Scroll to previous change',
'prev_change p scroll_to prev-change',
)
map('Show all context',
'all_context a change_context all',
)
map('Show default context',
'default_context = change_context default',
)
map('Increase context',
'increase_context + change_context 5',
)
map('Decrease context',
'decrease_context - change_context -5',
)
map('Search forward',
'search_forward / start_search regex forward',
)
map('Search backward',
'search_backward ? start_search regex backward',
)
map('Scroll to next search match',
'next_match . scroll_to next-match',
)
map('Scroll to next search match',
'next_match > scroll_to next-match',
)
map('Scroll to previous search match',
'prev_match , scroll_to prev-match',
)
map('Scroll to previous search match',
'prev_match < scroll_to prev-match',
)
map('Search forward (no regex)',
'search_forward_simple f start_search substring forward',
)
map('Search backward (no regex)',
'search_backward_simple b start_search substring backward',
)
egr() # }}}

View File

@ -1,125 +0,0 @@
# generated by gen-config.py DO NOT edit
# isort: skip_file
import typing
from kittens.diff.options.utils import parse_map, store_multiple, syntax_aliases
from kitty.conf.utils import merge_dicts, positive_int, python_string, to_color, to_color_or_none
class Parser:
def added_bg(self, val: str, ans: typing.Dict[str, typing.Any]) -> None:
ans['added_bg'] = to_color(val)
def added_margin_bg(self, val: str, ans: typing.Dict[str, typing.Any]) -> None:
ans['added_margin_bg'] = to_color(val)
def background(self, val: str, ans: typing.Dict[str, typing.Any]) -> None:
ans['background'] = to_color(val)
def diff_cmd(self, val: str, ans: typing.Dict[str, typing.Any]) -> None:
ans['diff_cmd'] = str(val)
def filler_bg(self, val: str, ans: typing.Dict[str, typing.Any]) -> None:
ans['filler_bg'] = to_color(val)
def foreground(self, val: str, ans: typing.Dict[str, typing.Any]) -> None:
ans['foreground'] = to_color(val)
def highlight_added_bg(self, val: str, ans: typing.Dict[str, typing.Any]) -> None:
ans['highlight_added_bg'] = to_color(val)
def highlight_removed_bg(self, val: str, ans: typing.Dict[str, typing.Any]) -> None:
ans['highlight_removed_bg'] = to_color(val)
def hunk_bg(self, val: str, ans: typing.Dict[str, typing.Any]) -> None:
ans['hunk_bg'] = to_color(val)
def hunk_margin_bg(self, val: str, ans: typing.Dict[str, typing.Any]) -> None:
ans['hunk_margin_bg'] = to_color(val)
def ignore_name(self, val: str, ans: typing.Dict[str, typing.Any]) -> None:
for k, v in store_multiple(val, ans["ignore_name"]):
ans["ignore_name"][k] = v
def margin_bg(self, val: str, ans: typing.Dict[str, typing.Any]) -> None:
ans['margin_bg'] = to_color(val)
def margin_fg(self, val: str, ans: typing.Dict[str, typing.Any]) -> None:
ans['margin_fg'] = to_color(val)
def margin_filler_bg(self, val: str, ans: typing.Dict[str, typing.Any]) -> None:
ans['margin_filler_bg'] = to_color_or_none(val)
def num_context_lines(self, val: str, ans: typing.Dict[str, typing.Any]) -> None:
ans['num_context_lines'] = positive_int(val)
def pygments_style(self, val: str, ans: typing.Dict[str, typing.Any]) -> None:
ans['pygments_style'] = str(val)
def removed_bg(self, val: str, ans: typing.Dict[str, typing.Any]) -> None:
ans['removed_bg'] = to_color(val)
def removed_margin_bg(self, val: str, ans: typing.Dict[str, typing.Any]) -> None:
ans['removed_margin_bg'] = to_color(val)
def replace_tab_by(self, val: str, ans: typing.Dict[str, typing.Any]) -> None:
ans['replace_tab_by'] = python_string(val)
def search_bg(self, val: str, ans: typing.Dict[str, typing.Any]) -> None:
ans['search_bg'] = to_color(val)
def search_fg(self, val: str, ans: typing.Dict[str, typing.Any]) -> None:
ans['search_fg'] = to_color(val)
def select_bg(self, val: str, ans: typing.Dict[str, typing.Any]) -> None:
ans['select_bg'] = to_color(val)
def select_fg(self, val: str, ans: typing.Dict[str, typing.Any]) -> None:
ans['select_fg'] = to_color_or_none(val)
def syntax_aliases(self, val: str, ans: typing.Dict[str, typing.Any]) -> None:
ans['syntax_aliases'] = syntax_aliases(val)
def title_bg(self, val: str, ans: typing.Dict[str, typing.Any]) -> None:
ans['title_bg'] = to_color(val)
def title_fg(self, val: str, ans: typing.Dict[str, typing.Any]) -> None:
ans['title_fg'] = to_color(val)
def map(self, val: str, ans: typing.Dict[str, typing.Any]) -> None:
for k in parse_map(val):
ans['map'].append(k)
def create_result_dict() -> typing.Dict[str, typing.Any]:
return {
'ignore_name': {},
'map': [],
}
actions: typing.FrozenSet[str] = frozenset(('map',))
def merge_result_dicts(defaults: typing.Dict[str, typing.Any], vals: typing.Dict[str, typing.Any]) -> typing.Dict[str, typing.Any]:
ans = {}
for k, v in defaults.items():
if isinstance(v, dict):
ans[k] = merge_dicts(v, vals.get(k, {}))
elif k in actions:
ans[k] = v + vals.get(k, [])
else:
ans[k] = vals.get(k, v)
return ans
parser = Parser()
def parse_conf_item(key: str, val: str, ans: typing.Dict[str, typing.Any]) -> bool:
func = getattr(parser, key, None)
if func is not None:
func(val, ans)
return True
return False

View File

@ -1,174 +0,0 @@
# generated by gen-config.py DO NOT edit
# isort: skip_file
import typing
from kitty.conf.utils import KeyAction, KittensKeyMap
import kitty.conf.utils
from kitty.fast_data_types import Color
import kitty.fast_data_types
from kitty.types import ParsedShortcut
import kitty.types
option_names = ( # {{{
'added_bg',
'added_margin_bg',
'background',
'diff_cmd',
'filler_bg',
'foreground',
'highlight_added_bg',
'highlight_removed_bg',
'hunk_bg',
'hunk_margin_bg',
'ignore_name',
'map',
'margin_bg',
'margin_fg',
'margin_filler_bg',
'num_context_lines',
'pygments_style',
'removed_bg',
'removed_margin_bg',
'replace_tab_by',
'search_bg',
'search_fg',
'select_bg',
'select_fg',
'syntax_aliases',
'title_bg',
'title_fg') # }}}
class Options:
added_bg: Color = Color(230, 255, 237)
added_margin_bg: Color = Color(205, 255, 216)
background: Color = Color(255, 255, 255)
diff_cmd: str = 'auto'
filler_bg: Color = Color(250, 251, 252)
foreground: Color = Color(0, 0, 0)
highlight_added_bg: Color = Color(172, 242, 189)
highlight_removed_bg: Color = Color(253, 184, 192)
hunk_bg: Color = Color(241, 248, 255)
hunk_margin_bg: Color = Color(219, 237, 255)
margin_bg: Color = Color(250, 251, 252)
margin_fg: Color = Color(170, 170, 170)
margin_filler_bg: typing.Optional[kitty.fast_data_types.Color] = None
num_context_lines: int = 3
pygments_style: str = 'default'
removed_bg: Color = Color(255, 238, 240)
removed_margin_bg: Color = Color(255, 220, 224)
replace_tab_by: str = ' '
search_bg: Color = Color(68, 68, 68)
search_fg: Color = Color(255, 255, 255)
select_bg: Color = Color(180, 213, 254)
select_fg: typing.Optional[kitty.fast_data_types.Color] = Color(0, 0, 0)
syntax_aliases: typing.Dict[str, str] = {'pyj': 'py', 'pyi': 'py', 'recipe': 'py'}
title_bg: Color = Color(255, 255, 255)
title_fg: Color = Color(0, 0, 0)
ignore_name: typing.Dict[str, str] = {}
map: typing.List[typing.Tuple[kitty.types.ParsedShortcut, kitty.conf.utils.KeyAction]] = []
key_definitions: KittensKeyMap = {}
config_paths: typing.Tuple[str, ...] = ()
config_overrides: typing.Tuple[str, ...] = ()
def __init__(self, options_dict: typing.Optional[typing.Dict[str, typing.Any]] = None) -> None:
if options_dict is not None:
null = object()
for key in option_names:
val = options_dict.get(key, null)
if val is not null:
setattr(self, key, val)
@property
def _fields(self) -> typing.Tuple[str, ...]:
return option_names
def __iter__(self) -> typing.Iterator[str]:
return iter(self._fields)
def __len__(self) -> int:
return len(self._fields)
def _copy_of_val(self, name: str) -> typing.Any:
ans = getattr(self, name)
if isinstance(ans, dict):
ans = ans.copy()
elif isinstance(ans, list):
ans = ans[:]
return ans
def _asdict(self) -> typing.Dict[str, typing.Any]:
return {k: self._copy_of_val(k) for k in self}
def _replace(self, **kw: typing.Any) -> "Options":
ans = Options()
for name in self:
setattr(ans, name, self._copy_of_val(name))
for name, val in kw.items():
setattr(ans, name, val)
return ans
def __getitem__(self, key: typing.Union[int, str]) -> typing.Any:
k = option_names[key] if isinstance(key, int) else key
try:
return getattr(self, k)
except AttributeError:
pass
raise KeyError(f"No option named: {k}")
defaults = Options()
defaults.ignore_name = {}
defaults.map = [
# quit
(ParsedShortcut(mods=0, key_name='q'), KeyAction('quit')),
# quit
(ParsedShortcut(mods=0, key_name='ESCAPE'), KeyAction('quit')),
# scroll_down
(ParsedShortcut(mods=0, key_name='j'), KeyAction('scroll_by', (1,))),
# scroll_down
(ParsedShortcut(mods=0, key_name='DOWN'), KeyAction('scroll_by', (1,))),
# scroll_up
(ParsedShortcut(mods=0, key_name='k'), KeyAction('scroll_by', (-1,))),
# scroll_up
(ParsedShortcut(mods=0, key_name='UP'), KeyAction('scroll_by', (-1,))),
# scroll_top
(ParsedShortcut(mods=0, key_name='HOME'), KeyAction('scroll_to', ('start',))),
# scroll_bottom
(ParsedShortcut(mods=0, key_name='END'), KeyAction('scroll_to', ('end',))),
# scroll_page_down
(ParsedShortcut(mods=0, key_name='PAGE_DOWN'), KeyAction('scroll_to', ('next-page',))),
# scroll_page_down
(ParsedShortcut(mods=0, key_name=' '), KeyAction('scroll_to', ('next-page',))),
# scroll_page_up
(ParsedShortcut(mods=0, key_name='PAGE_UP'), KeyAction('scroll_to', ('prev-page',))),
# next_change
(ParsedShortcut(mods=0, key_name='n'), KeyAction('scroll_to', ('next-change',))),
# prev_change
(ParsedShortcut(mods=0, key_name='p'), KeyAction('scroll_to', ('prev-change',))),
# all_context
(ParsedShortcut(mods=0, key_name='a'), KeyAction('change_context', ('all',))),
# default_context
(ParsedShortcut(mods=0, key_name='='), KeyAction('change_context', ('default',))),
# increase_context
(ParsedShortcut(mods=0, key_name='+'), KeyAction('change_context', (5,))),
# decrease_context
(ParsedShortcut(mods=0, key_name='-'), KeyAction('change_context', (-5,))),
# search_forward
(ParsedShortcut(mods=0, key_name='/'), KeyAction('start_search', (True, False))),
# search_backward
(ParsedShortcut(mods=0, key_name='?'), KeyAction('start_search', (True, True))),
# next_match
(ParsedShortcut(mods=0, key_name='.'), KeyAction('scroll_to', ('next-match',))),
# next_match
(ParsedShortcut(mods=0, key_name='>'), KeyAction('scroll_to', ('next-match',))),
# prev_match
(ParsedShortcut(mods=0, key_name=','), KeyAction('scroll_to', ('prev-match',))),
# prev_match
(ParsedShortcut(mods=0, key_name='<'), KeyAction('scroll_to', ('prev-match',))),
# search_forward_simple
(ParsedShortcut(mods=0, key_name='f'), KeyAction('start_search', (False, False))),
# search_backward_simple
(ParsedShortcut(mods=0, key_name='b'), KeyAction('start_search', (False, True))),
]

View File

@ -1,68 +0,0 @@
#!/usr/bin/env python
# vim:fileencoding=utf-8
# License: GPLv3 Copyright: 2021, Kovid Goyal <kovid at kovidgoyal.net>
from typing import Any, Container, Dict, Iterable, Tuple, Union
from kitty.conf.utils import KeyFuncWrapper, KittensKeyDefinition, parse_kittens_key
ReturnType = Tuple[str, Any]
func_with_args = KeyFuncWrapper[ReturnType]()
@func_with_args('scroll_by')
def parse_scroll_by(func: str, rest: str) -> Tuple[str, int]:
try:
return func, int(rest)
except Exception:
return func, 1
@func_with_args('scroll_to')
def parse_scroll_to(func: str, rest: str) -> Tuple[str, str]:
rest = rest.lower()
if rest not in {'start', 'end', 'next-change', 'prev-change', 'next-page', 'prev-page', 'next-match', 'prev-match'}:
rest = 'start'
return func, rest
@func_with_args('change_context')
def parse_change_context(func: str, rest: str) -> Tuple[str, Union[int, str]]:
rest = rest.lower()
if rest in {'all', 'default'}:
return func, rest
try:
amount = int(rest)
except Exception:
amount = 5
return func, amount
@func_with_args('start_search')
def parse_start_search(func: str, rest: str) -> Tuple[str, Tuple[bool, bool]]:
rest_ = rest.lower().split()
is_regex = bool(rest_ and rest_[0] == 'regex')
is_backward = bool(len(rest_) > 1 and rest_[1] == 'backward')
return func, (is_regex, is_backward)
def syntax_aliases(raw: str) -> Dict[str, str]:
ans = {}
for x in raw.split():
a, b = x.partition(':')[::2]
if a and b:
ans[a.lower()] = b
return ans
def store_multiple(val: str, current_val: Container[str]) -> Iterable[Tuple[str, str]]:
val = val.strip()
if val not in current_val:
yield val, val
def parse_map(val: str) -> Iterable[KittensKeyDefinition]:
x = parse_kittens_key(val, func_with_args.args_funcs)
if x is not None:
yield x

View File

@ -1,262 +0,0 @@
#!/usr/bin/env python3
# License: GPL v3 Copyright: 2018, Kovid Goyal <kovid at kovidgoyal.net>
import concurrent.futures
import os
import shlex
import shutil
import subprocess
from typing import Dict, Iterator, List, Optional, Sequence, Tuple, Union
from . import global_data
from .collect import lines_for_path
from .diff_speedup import changed_center, splitlines_like_git
left_lines: Tuple[str, ...] = ()
right_lines: Tuple[str, ...] = ()
GIT_DIFF = 'git diff --no-color --no-ext-diff --exit-code -U_CONTEXT_ --no-index --'
DIFF_DIFF = 'diff -p -U _CONTEXT_ --'
worker_processes: List[int] = []
def find_differ() -> Optional[str]:
if shutil.which('git') and subprocess.Popen(['git', '--help'], stderr=subprocess.DEVNULL, stdout=subprocess.DEVNULL).wait() == 0:
return GIT_DIFF
if shutil.which('diff'):
return DIFF_DIFF
return None
def set_diff_command(opt: str) -> None:
if opt == 'auto':
cmd = find_differ()
if cmd is None:
raise SystemExit('Failed to find either the git or diff programs on your system')
else:
cmd = opt
global_data.cmd = cmd
def run_diff(file1: str, file2: str, context: int = 3) -> Tuple[bool, Union[int, bool], bytes]:
# returns: ok, is_different, patch
cmd = shlex.split(global_data.cmd.replace('_CONTEXT_', str(context)))
# we resolve symlinks because git diff does not follow symlinks, while diff
# does. We want consistent behavior, also for integration with git difftool
# we always want symlinks to be followed.
path1 = os.path.realpath(file1)
path2 = os.path.realpath(file2)
p = subprocess.Popen(
cmd + [path1, path2],
stdout=subprocess.PIPE, stderr=subprocess.PIPE, stdin=subprocess.DEVNULL)
worker_processes.append(p.pid)
stdout, stderr = p.communicate()
returncode = p.wait()
worker_processes.remove(p.pid)
if returncode in (0, 1):
return True, returncode == 1, stdout
return False, returncode, stderr
class Chunk:
__slots__ = ('is_context', 'left_start', 'right_start', 'left_count', 'right_count', 'centers')
def __init__(self, left_start: int, right_start: int, is_context: bool = False) -> None:
self.is_context = is_context
self.left_start = left_start
self.right_start = right_start
self.left_count = self.right_count = 0
self.centers: Optional[Tuple[Tuple[int, int], ...]] = None
def add_line(self) -> None:
self.right_count += 1
def remove_line(self) -> None:
self.left_count += 1
def context_line(self) -> None:
self.left_count += 1
self.right_count += 1
def finalize(self) -> None:
if not self.is_context and self.left_count == self.right_count:
self.centers = tuple(
changed_center(left_lines[self.left_start + i], right_lines[self.right_start + i])
for i in range(self.left_count)
)
def __repr__(self) -> str:
return 'Chunk(is_context={}, left_start={}, left_count={}, right_start={}, right_count={})'.format(
self.is_context, self.left_start, self.left_count, self.right_start, self.right_count)
class Hunk:
def __init__(self, title: str, left: Tuple[int, int], right: Tuple[int, int]) -> None:
self.left_start, self.left_count = left
self.right_start, self.right_count = right
self.left_start -= 1 # 0-index
self.right_start -= 1 # 0-index
self.title = title
self.added_count = self.removed_count = 0
self.chunks: List[Chunk] = []
self.current_chunk: Optional[Chunk] = None
self.largest_line_number = max(self.left_start + self.left_count, self.right_start + self.right_count)
def new_chunk(self, is_context: bool = False) -> Chunk:
if self.chunks:
c = self.chunks[-1]
left_start = c.left_start + c.left_count
right_start = c.right_start + c.right_count
else:
left_start = self.left_start
right_start = self.right_start
return Chunk(left_start, right_start, is_context)
def ensure_diff_chunk(self) -> None:
if self.current_chunk is None:
self.current_chunk = self.new_chunk(is_context=False)
elif self.current_chunk.is_context:
self.chunks.append(self.current_chunk)
self.current_chunk = self.new_chunk(is_context=False)
def ensure_context_chunk(self) -> None:
if self.current_chunk is None:
self.current_chunk = self.new_chunk(is_context=True)
elif not self.current_chunk.is_context:
self.chunks.append(self.current_chunk)
self.current_chunk = self.new_chunk(is_context=True)
def add_line(self) -> None:
self.ensure_diff_chunk()
if self.current_chunk is not None:
self.current_chunk.add_line()
self.added_count += 1
def remove_line(self) -> None:
self.ensure_diff_chunk()
if self.current_chunk is not None:
self.current_chunk.remove_line()
self.removed_count += 1
def context_line(self) -> None:
self.ensure_context_chunk()
if self.current_chunk is not None:
self.current_chunk.context_line()
def finalize(self) -> None:
if self.current_chunk is not None:
self.chunks.append(self.current_chunk)
del self.current_chunk
# Sanity check
c = self.chunks[-1]
if c.left_start + c.left_count != self.left_start + self.left_count:
raise ValueError(f'Left side line mismatch {c.left_start + c.left_count} != {self.left_start + self.left_count}')
if c.right_start + c.right_count != self.right_start + self.right_count:
raise ValueError(f'Right side line mismatch {c.right_start + c.right_count} != {self.right_start + self.right_count}')
for c in self.chunks:
c.finalize()
def parse_range(x: bytes) -> Tuple[int, int]:
parts = x[1:].split(b',', 1)
start = abs(int(parts[0]))
count = 1 if len(parts) < 2 else int(parts[1])
return start, count
def parse_hunk_header(line: bytes) -> Hunk:
parts: Tuple[bytes, ...] = tuple(filter(None, line.split(b'@@', 2)))
linespec = parts[0].strip()
title = ''
if len(parts) == 2:
title = parts[1].strip().decode('utf-8', 'replace')
left, right = map(parse_range, linespec.split())
return Hunk(title, left, right)
class Patch:
def __init__(self, all_hunks: Sequence[Hunk]):
self.all_hunks = all_hunks
self.largest_line_number = self.all_hunks[-1].largest_line_number if self.all_hunks else 0
self.added_count = sum(h.added_count for h in all_hunks)
self.removed_count = sum(h.removed_count for h in all_hunks)
def __iter__(self) -> Iterator[Hunk]:
return iter(self.all_hunks)
def __len__(self) -> int:
return len(self.all_hunks)
def parse_patch(raw: bytes) -> Patch:
all_hunks = []
current_hunk = None
plus, minus, backslash = map(ord, '+-\\')
def parse_line(line: memoryview) -> None:
nonlocal current_hunk
if line[:3] == b'@@ ':
current_hunk = parse_hunk_header(bytes(line))
all_hunks.append(current_hunk)
else:
if current_hunk is None:
return
q:int = line[0] if len(line) > 0 else 0
if q == plus:
current_hunk.add_line()
elif q == minus:
current_hunk.remove_line()
elif q == backslash:
return
else:
current_hunk.context_line()
splitlines_like_git(raw, parse_line)
for h in all_hunks:
h.finalize()
return Patch(all_hunks)
class Differ:
diff_executor: Optional[concurrent.futures.ThreadPoolExecutor] = None
def __init__(self) -> None:
self.jmap: Dict[str, str] = {}
self.jobs: List[str] = []
if Differ.diff_executor is None:
Differ.diff_executor = self.diff_executor = concurrent.futures.ThreadPoolExecutor(max_workers=os.cpu_count())
def add_diff(self, file1: str, file2: str) -> None:
self.jmap[file1] = file2
self.jobs.append(file1)
def __call__(self, context: int = 3) -> Union[str, Dict[str, Patch]]:
global left_lines, right_lines
ans: Dict[str, Patch] = {}
executor = self.diff_executor
assert executor is not None
jobs = {executor.submit(run_diff, key, self.jmap[key], context): key for key in self.jobs}
for future in concurrent.futures.as_completed(jobs):
key = jobs[future]
left_path, right_path = key, self.jmap[key]
try:
ok, returncode, output = future.result()
except FileNotFoundError as err:
return f'Could not find the {err.filename} executable. Is it in your PATH?'
except Exception as e:
return f'Running git diff for {left_path} vs. {right_path} generated an exception: {e}'
if not ok:
return f'{output.decode("utf-8", "replace")}\nRunning git diff for {left_path} vs. {right_path} failed'
left_lines = lines_for_path(left_path)
right_lines = lines_for_path(right_path)
try:
patch = parse_patch(output)
except Exception:
import traceback
return f'{traceback.format_exc()}\nParsing diff for {left_path} vs. {right_path} failed'
else:
ans[key] = patch
return ans

View File

@ -1,546 +0,0 @@
#!/usr/bin/env python3
# License: GPL v3 Copyright: 2018, Kovid Goyal <kovid at kovidgoyal.net>
import warnings
from gettext import gettext as _
from itertools import repeat, zip_longest
from math import ceil
from typing import Callable, Dict, Generator, Iterable, List, Optional, Tuple
from kitty.cli_stub import DiffCLIOptions
from kitty.fast_data_types import truncate_point_for_length, wcswidth
from kitty.types import run_once
from kitty.utils import ScreenSize
from ..tui.images import ImageManager, can_display_images
from .collect import Collection, Segment, data_for_path, highlights_for_path, is_image, lines_for_path, path_name_map, sanitize
from .config import formats
from .diff_speedup import split_with_highlights as _split_with_highlights
from .patch import Chunk, Hunk, Patch
class ImageSupportWarning(Warning):
pass
@run_once
def images_supported() -> bool:
ans = can_display_images()
if not ans:
warnings.warn('ImageMagick not found images cannot be displayed', ImageSupportWarning)
return ans
class Ref:
__slots__: Tuple[str, ...] = ()
def __setattr__(self, name: str, value: object) -> None:
raise AttributeError("can't set attribute")
def __repr__(self) -> str:
return '{}({})'.format(self.__class__.__name__, ', '.join(
f'{n}={getattr(self, n)}' for n in self.__slots__ if n != '_hash'))
class LineRef(Ref):
__slots__ = ('src_line_number', 'wrapped_line_idx')
src_line_number: int
wrapped_line_idx: int
def __init__(self, sln: int, wli: int = 0) -> None:
object.__setattr__(self, 'src_line_number', sln)
object.__setattr__(self, 'wrapped_line_idx', wli)
class Reference(Ref):
__slots__ = ('path', 'extra')
path: str
extra: Optional[LineRef]
def __init__(self, path: str, extra: Optional[LineRef] = None) -> None:
object.__setattr__(self, 'path', path)
object.__setattr__(self, 'extra', extra)
class Line:
__slots__ = ('text', 'ref', 'is_change_start', 'image_data')
def __init__(
self,
text: str,
ref: Reference,
change_start: bool = False,
image_data: Optional[Tuple[Optional['ImagePlacement'], Optional['ImagePlacement']]] = None
) -> None:
self.text = text
self.ref = ref
self.is_change_start = change_start
self.image_data = image_data
def yield_lines_from(iterator: Iterable[str], reference: Reference, is_change_start: bool = True) -> Generator[Line, None, None]:
for text in iterator:
yield Line(text, reference, is_change_start)
is_change_start = False
def human_readable(size: int, sep: str = ' ') -> str:
""" Convert a size in bytes into a human readable form """
divisor, suffix = 1, "B"
for i, candidate in enumerate(('B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB')):
if size < (1 << ((i + 1) * 10)):
divisor, suffix = (1 << (i * 10)), candidate
break
s = str(float(size)/divisor)
if s.find(".") > -1:
s = s[:s.find(".")+2]
if s.endswith('.0'):
s = s[:-2]
return f'{s}{sep}{suffix}'
def fit_in(text: str, count: int) -> str:
p = truncate_point_for_length(text, count)
if p >= len(text):
return text
if count > 1:
p = truncate_point_for_length(text, count - 1)
return f'{text[:p]}'
def fill_in(text: str, sz: int) -> str:
w = wcswidth(text)
if w < sz:
text += ' ' * (sz - w)
return text
def place_in(text: str, sz: int) -> str:
return fill_in(fit_in(text, sz), sz)
def format_func(which: str) -> Callable[[str], str]:
def formatted(text: str) -> str:
fmt = formats[which]
return f'\x1b[{fmt}m{text}\x1b[0m'
formatted.__name__ = f'{which}_format'
return formatted
text_format = format_func('text')
title_format = format_func('title')
margin_format = format_func('margin')
added_format = format_func('added')
removed_format = format_func('removed')
removed_margin_format = format_func('removed_margin')
added_margin_format = format_func('added_margin')
filler_format = format_func('filler')
margin_filler_format = format_func('margin_filler')
hunk_margin_format = format_func('hunk_margin')
hunk_format = format_func('hunk')
highlight_map = {'remove': ('removed_highlight', 'removed'), 'add': ('added_highlight', 'added')}
def highlight_boundaries(ltype: str) -> Tuple[str, str]:
s, e = highlight_map[ltype]
start = f'\x1b[{formats[s]}m'
stop = f'\x1b[{formats[e]}m'
return start, stop
def title_lines(left_path: Optional[str], right_path: Optional[str], args: DiffCLIOptions, columns: int, margin_size: int) -> Generator[str, None, None]:
m = ' ' * margin_size
left_name = path_name_map.get(left_path) if left_path else None
right_name = path_name_map.get(right_path) if right_path else None
if right_name and right_name != left_name:
n1 = fit_in(m + sanitize(left_name or ''), columns // 2 - margin_size)
n1 = place_in(n1, columns // 2)
n2 = fit_in(m + sanitize(right_name), columns // 2 - margin_size)
n2 = place_in(n2, columns // 2)
name = n1 + n2
else:
name = place_in(m + sanitize(left_name or ''), columns)
yield title_format(place_in(name, columns))
yield title_format('' * columns)
def binary_lines(path: Optional[str], other_path: Optional[str], columns: int, margin_size: int) -> Generator[str, None, None]:
template = _('Binary file: {}')
available_cols = columns // 2 - margin_size
def fl(path: str, fmt: Callable[[str], str]) -> str:
text = template.format(human_readable(len(data_for_path(path))))
text = place_in(text, available_cols)
return margin_format(' ' * margin_size) + fmt(text)
if path is None:
filler = render_diff_line('', '', 'filler', margin_size, available_cols)
assert other_path is not None
yield filler + fl(other_path, added_format)
elif other_path is None:
filler = render_diff_line('', '', 'filler', margin_size, available_cols)
yield fl(path, removed_format) + filler
else:
yield fl(path, removed_format) + fl(other_path, added_format)
def split_to_size(line: str, width: int) -> Generator[str, None, None]:
if not line:
yield line
while line:
p = truncate_point_for_length(line, width)
yield line[:p]
line = line[p:]
def truncate_points(line: str, width: int) -> Generator[int, None, None]:
pos = 0
sz = len(line)
while True:
pos = truncate_point_for_length(line, width, pos)
if pos < sz:
yield pos
else:
break
def split_with_highlights(line: str, width: int, highlights: List[Segment], bg_highlight: Optional[Segment] = None) -> List[str]:
truncate_pts = list(truncate_points(line, width))
return _split_with_highlights(line, truncate_pts, highlights, bg_highlight)
margin_bg_map = {'filler': margin_filler_format, 'remove': removed_margin_format, 'add': added_margin_format, 'context': margin_format}
text_bg_map = {'filler': filler_format, 'remove': removed_format, 'add': added_format, 'context': text_format}
class DiffData:
def __init__(self, left_path: str, right_path: str, available_cols: int, margin_size: int):
self.left_path, self.right_path = left_path, right_path
self.available_cols = available_cols
self.margin_size = margin_size
self.left_lines, self.right_lines = map(lines_for_path, (left_path, right_path))
self.filler_line = render_diff_line('', '', 'filler', margin_size, available_cols)
self.left_filler_line = render_diff_line('', '', 'remove', margin_size, available_cols)
self.right_filler_line = render_diff_line('', '', 'add', margin_size, available_cols)
self.left_hdata = highlights_for_path(left_path)
self.right_hdata = highlights_for_path(right_path)
def left_highlights_for_line(self, line_num: int) -> List[Segment]:
if line_num < len(self.left_hdata):
return self.left_hdata[line_num]
return []
def right_highlights_for_line(self, line_num: int) -> List[Segment]:
if line_num < len(self.right_hdata):
return self.right_hdata[line_num]
return []
def render_diff_line(number: Optional[str], text: str, ltype: str, margin_size: int, available_cols: int) -> str:
margin = margin_bg_map[ltype](place_in(number or '', margin_size))
content = text_bg_map[ltype](fill_in(text or '', available_cols))
return margin + content
def render_diff_pair(
left_line_number: Optional[str], left: str, left_is_change: bool,
right_line_number: Optional[str], right: str, right_is_change: bool,
is_first: bool, margin_size: int, available_cols: int
) -> str:
ltype = 'filler' if left_line_number is None else ('remove' if left_is_change else 'context')
rtype = 'filler' if right_line_number is None else ('add' if right_is_change else 'context')
return (
render_diff_line(left_line_number if is_first else None, left, ltype, margin_size, available_cols) +
render_diff_line(right_line_number if is_first else None, right, rtype, margin_size, available_cols)
)
def hunk_title(hunk_num: int, hunk: Hunk, margin_size: int, available_cols: int) -> str:
m = hunk_margin_format(' ' * margin_size)
t = f'@@ -{hunk.left_start + 1},{hunk.left_count} +{hunk.right_start + 1},{hunk.right_count} @@ {hunk.title}'
return m + hunk_format(place_in(t, available_cols))
def render_half_line(
line_number: int,
line: str,
highlights: List[Segment],
ltype: str,
margin_size: int,
available_cols: int,
changed_center: Optional[Tuple[int, int]] = None
) -> Generator[str, None, None]:
bg_highlight: Optional[Segment] = None
if changed_center is not None and changed_center[0]:
prefix_count, suffix_count = changed_center
line_sz = len(line)
if prefix_count + suffix_count < line_sz:
start, stop = highlight_boundaries(ltype)
seg = Segment(prefix_count, start)
seg.end = line_sz - suffix_count
seg.end_code = stop
bg_highlight = seg
if highlights or bg_highlight:
lines: Iterable[str] = split_with_highlights(line, available_cols, highlights, bg_highlight)
else:
lines = split_to_size(line, available_cols)
lnum = str(line_number + 1)
for line in lines:
yield render_diff_line(lnum, line, ltype, margin_size, available_cols)
lnum = ''
def lines_for_chunk(data: DiffData, hunk_num: int, chunk: Chunk, chunk_num: int) -> Generator[Line, None, None]:
if chunk.is_context:
for i in range(chunk.left_count):
left_line_number = line_ref = chunk.left_start + i
right_line_number = chunk.right_start + i
highlights = data.left_highlights_for_line(left_line_number)
if highlights:
lines: Iterable[str] = split_with_highlights(data.left_lines[left_line_number], data.available_cols, highlights)
else:
lines = split_to_size(data.left_lines[left_line_number], data.available_cols)
left_line_number_s = str(left_line_number + 1)
right_line_number_s = str(right_line_number + 1)
for wli, text in enumerate(lines):
line = render_diff_line(left_line_number_s, text, 'context', data.margin_size, data.available_cols)
if right_line_number_s == left_line_number_s:
r = line
else:
r = render_diff_line(right_line_number_s, text, 'context', data.margin_size, data.available_cols)
ref = Reference(data.left_path, LineRef(line_ref, wli))
yield Line(line + r, ref)
left_line_number_s = right_line_number_s = ''
else:
common = min(chunk.left_count, chunk.right_count)
for i in range(max(chunk.left_count, chunk.right_count)):
ll: List[str] = []
rl: List[str] = []
if i < chunk.left_count:
rln = ref_ln = chunk.left_start + i
ll.extend(render_half_line(
rln, data.left_lines[rln], data.left_highlights_for_line(rln),
'remove', data.margin_size, data.available_cols,
None if chunk.centers is None else chunk.centers[i]))
ref_path = data.left_path
if i < chunk.right_count:
rln = ref_ln = chunk.right_start + i
rl.extend(render_half_line(
rln, data.right_lines[rln], data.right_highlights_for_line(rln),
'add', data.margin_size, data.available_cols,
None if chunk.centers is None else chunk.centers[i]))
ref_path = data.right_path
if i < common:
extra = len(ll) - len(rl)
if extra != 0:
if extra < 0:
x, fl = ll, data.left_filler_line
extra = -extra
else:
x, fl = rl, data.right_filler_line
x.extend(repeat(fl, extra))
else:
if ll:
x, count = rl, len(ll)
else:
x, count = ll, len(rl)
x.extend(repeat(data.filler_line, count))
for wli, (left_line, right_line) in enumerate(zip(ll, rl)):
ref = Reference(ref_path, LineRef(ref_ln, wli))
yield Line(left_line + right_line, ref, i == 0 and wli == 0)
def lines_for_diff(left_path: str, right_path: str, hunks: Iterable[Hunk], args: DiffCLIOptions, columns: int, margin_size: int) -> Generator[Line, None, None]:
available_cols = columns // 2 - margin_size
data = DiffData(left_path, right_path, available_cols, margin_size)
for hunk_num, hunk in enumerate(hunks):
yield Line(hunk_title(hunk_num, hunk, margin_size, columns - margin_size), Reference(left_path, LineRef(hunk.left_start)))
for cnum, chunk in enumerate(hunk.chunks):
yield from lines_for_chunk(data, hunk_num, chunk, cnum)
def all_lines(path: str, args: DiffCLIOptions, columns: int, margin_size: int, is_add: bool = True) -> Generator[Line, None, None]:
available_cols = columns // 2 - margin_size
ltype = 'add' if is_add else 'remove'
lines = lines_for_path(path)
filler = render_diff_line('', '', 'filler', margin_size, available_cols)
msg_written = False
hdata = highlights_for_path(path)
def highlights(num: int) -> List[Segment]:
return hdata[num] if num < len(hdata) else []
for line_number, line in enumerate(lines):
h = render_half_line(line_number, line, highlights(line_number), ltype, margin_size, available_cols)
for i, hl in enumerate(h):
ref = Reference(path, LineRef(line_number, i))
empty = filler
if not msg_written:
msg_written = True
empty = render_diff_line(
'', _('This file was added') if is_add else _('This file was removed'),
'filler', margin_size, available_cols)
text = (empty + hl) if is_add else (hl + empty)
yield Line(text, ref, line_number == 0 and i == 0)
def rename_lines(path: str, other_path: str, args: DiffCLIOptions, columns: int, margin_size: int) -> Generator[str, None, None]:
m = ' ' * margin_size
for line in split_to_size(_('The file {0} was renamed to {1}').format(
sanitize(path_name_map[path]), sanitize(path_name_map[other_path])), columns - margin_size):
yield m + line
class Image:
def __init__(self, image_id: int, width: int, height: int, margin_size: int, screen_size: ScreenSize) -> None:
self.image_id = image_id
self.width, self.height = width, height
self.rows = int(ceil(self.height / screen_size.cell_height))
self.columns = int(ceil(self.width / screen_size.cell_width))
self.margin_size = margin_size
class ImagePlacement:
def __init__(self, image: Image, row: int) -> None:
self.image = image
self.row = row
def render_image(
path: str,
is_left: bool,
available_cols: int, margin_size: int,
image_manager: ImageManager
) -> Generator[Tuple[str, Reference, Optional[ImagePlacement]], None, None]:
lnum = 0
margin_fmt = removed_margin_format if is_left else added_margin_format
m = margin_fmt(' ' * margin_size)
fmt = removed_format if is_left else added_format
def yield_split(text: str) -> Generator[Tuple[str, Reference, Optional[ImagePlacement]], None, None]:
nonlocal lnum
for i, line in enumerate(split_to_size(text, available_cols)):
yield m + fmt(place_in(line, available_cols)), Reference(path, LineRef(lnum, i)), None
lnum += 1
try:
image_id, width, height = image_manager.send_image(path, available_cols - margin_size, image_manager.screen_size.rows - 2)
except Exception as e:
yield from yield_split(_('Failed to render image, with error:'))
yield from yield_split(' '.join(str(e).splitlines()))
return
meta = _('Dimensions: {0}x{1} pixels Size: {2}').format(
width, height, human_readable(len(data_for_path(path))))
yield from yield_split(meta)
bg_line = m + fmt(' ' * available_cols)
img = Image(image_id, width, height, margin_size, image_manager.screen_size)
for r in range(img.rows):
yield bg_line, Reference(path, LineRef(lnum)), ImagePlacement(img, r)
lnum += 1
def image_lines(
left_path: Optional[str],
right_path: Optional[str],
columns: int,
margin_size: int,
image_manager: ImageManager
) -> Generator[Line, None, None]:
available_cols = columns // 2 - margin_size
left_lines: Iterable[Tuple[str, Reference, Optional[ImagePlacement]]] = iter(())
right_lines: Iterable[Tuple[str, Reference, Optional[ImagePlacement]]] = iter(())
if left_path is not None:
left_lines = render_image(left_path, True, available_cols, margin_size, image_manager)
if right_path is not None:
right_lines = render_image(right_path, False, available_cols, margin_size, image_manager)
filler = ' ' * (available_cols + margin_size)
is_change_start = True
for left, right in zip_longest(left_lines, right_lines):
left_placement = right_placement = None
if left is None:
left = filler
right, ref, right_placement = right
elif right is None:
right = filler
left, ref, left_placement = left
else:
right, ref, right_placement = right
left, ref, left_placement = left
image_data = (left_placement, right_placement) if left_placement or right_placement else None
yield Line(left + right, ref, is_change_start, image_data)
is_change_start = False
class RenderDiff:
margin_size: int = 0
def __call__(
self,
collection: Collection,
diff_map: Dict[str, Patch],
args: DiffCLIOptions,
columns: int,
image_manager: ImageManager
) -> Generator[Line, None, None]:
largest_line_number = 0
for path, item_type, other_path in collection:
if item_type == 'diff':
patch = diff_map.get(path)
if patch is not None:
largest_line_number = max(largest_line_number, patch.largest_line_number)
margin_size = self.margin_size = max(3, len(str(largest_line_number)) + 1)
last_item_num = len(collection) - 1
for i, (path, item_type, other_path) in enumerate(collection):
item_ref = Reference(path)
is_binary = isinstance(data_for_path(path), bytes)
if not is_binary and item_type == 'diff' and isinstance(data_for_path(other_path), bytes):
is_binary = True
is_img = is_binary and (is_image(path) or is_image(other_path)) and images_supported()
yield from yield_lines_from(title_lines(path, other_path, args, columns, margin_size), item_ref, False)
if item_type == 'diff':
if is_binary:
if is_img:
ans = image_lines(path, other_path, columns, margin_size, image_manager)
else:
ans = yield_lines_from(binary_lines(path, other_path, columns, margin_size), item_ref)
else:
assert other_path is not None
ans = lines_for_diff(path, other_path, diff_map[path], args, columns, margin_size)
elif item_type == 'add':
if is_binary:
if is_img:
ans = image_lines(None, path, columns, margin_size, image_manager)
else:
ans = yield_lines_from(binary_lines(None, path, columns, margin_size), item_ref)
else:
ans = all_lines(path, args, columns, margin_size, is_add=True)
elif item_type == 'removal':
if is_binary:
if is_img:
ans = image_lines(path, None, columns, margin_size, image_manager)
else:
ans = yield_lines_from(binary_lines(path, None, columns, margin_size), item_ref)
else:
ans = all_lines(path, args, columns, margin_size, is_add=False)
elif item_type == 'rename':
assert other_path is not None
ans = yield_lines_from(rename_lines(path, other_path, args, columns, margin_size), item_ref)
else:
raise ValueError(f'Unsupported item type: {item_type}')
yield from ans
if i < last_item_num:
yield Line('', item_ref)
render_diff = RenderDiff()

View File

@ -1,72 +0,0 @@
#!/usr/bin/env python3
# License: GPL v3 Copyright: 2018, Kovid Goyal <kovid at kovidgoyal.net>
import re
from typing import TYPE_CHECKING, Callable, Dict, Iterable, List, Tuple
from kitty.fast_data_types import wcswidth
from ..tui.operations import styled
from .options.types import Options as DiffOptions
if TYPE_CHECKING:
from .render import Line
Line
class BadRegex(ValueError):
pass
class Search:
def __init__(self, opts: DiffOptions, query: str, is_regex: bool, is_backward: bool):
self.matches: Dict[int, List[Tuple[int, str]]] = {}
self.count = 0
self.style = styled('|', fg=opts.search_fg, bg=opts.search_bg).split('|', 1)[0]
if not is_regex:
query = re.escape(query)
try:
self.pat = re.compile(query, flags=re.UNICODE | re.IGNORECASE)
except Exception:
raise BadRegex(f'Not a valid regex: {query}')
def __call__(self, diff_lines: Iterable['Line'], margin_size: int, cols: int) -> bool:
self.matches = {}
self.count = 0
half_width = cols // 2
strip_pat = re.compile('\033[[].*?m')
right_offset = half_width + 1 + margin_size
find = self.pat.finditer
for i, line in enumerate(diff_lines):
text = strip_pat.sub('', line.text)
left, right = text[margin_size:half_width + 1], text[right_offset:]
matches = []
def add(which: str, offset: int) -> None:
for m in find(which):
before = which[:m.start()]
matches.append((wcswidth(before) + offset, m.group()))
self.count += 1
add(left, margin_size)
add(right, right_offset)
if matches:
self.matches[i] = matches
return bool(self.matches)
def __contains__(self, i: int) -> bool:
return i in self.matches
def __len__(self) -> int:
return self.count
def highlight_line(self, write: Callable[[str], None], line_num: int) -> bool:
highlights = self.matches.get(line_num)
if not highlights:
return False
write(self.style)
for start, text in highlights:
write(f'\r\x1b[{start}C{text}')
write('\x1b[m')
return True

View File

@ -1,239 +0,0 @@
/*
* speedup.c
* Copyright (C) 2018 Kovid Goyal <kovid at kovidgoyal.net>
*
* Distributed under terms of the GPL3 license.
*/
#include "data-types.h"
static PyObject*
changed_center(PyObject *self UNUSED, PyObject *args) {
unsigned int prefix_count = 0, suffix_count = 0;
PyObject *lp, *rp;
if (!PyArg_ParseTuple(args, "UU", &lp, &rp)) return NULL;
const size_t left_len = PyUnicode_GET_LENGTH(lp), right_len = PyUnicode_GET_LENGTH(rp);
#define R(which, index) PyUnicode_READ(PyUnicode_KIND(which), PyUnicode_DATA(which), index)
while(prefix_count < MIN(left_len, right_len)) {
if (R(lp, prefix_count) != R(rp, prefix_count)) break;
prefix_count++;
}
if (left_len && right_len && prefix_count < MIN(left_len, right_len)) {
while(suffix_count < MIN(left_len - prefix_count, right_len - prefix_count)) {
if(R(lp, left_len - 1 - suffix_count) != R(rp, right_len - 1 - suffix_count)) break;
suffix_count++;
}
}
#undef R
return Py_BuildValue("II", prefix_count, suffix_count);
}
typedef struct {
unsigned int start_pos, end_pos, current_pos;
PyObject *start_code, *end_code;
} Segment;
typedef struct {
Segment sg;
unsigned int num, pos;
} SegmentPointer;
static const Segment EMPTY_SEGMENT = { .current_pos = UINT_MAX };
static bool
convert_segment(PyObject *highlight, Segment *dest) {
PyObject *val = NULL;
#define I
#define A(x, d, c) { \
val = PyObject_GetAttrString(highlight, #x); \
if (val == NULL) return false; \
dest->d = c(val); Py_DECREF(val); \
}
A(start, start_pos, PyLong_AsUnsignedLong);
A(end, end_pos, PyLong_AsUnsignedLong);
dest->current_pos = dest->start_pos;
A(start_code, start_code, I);
A(end_code, end_code, I);
if (!PyUnicode_Check(dest->start_code)) { PyErr_SetString(PyExc_TypeError, "start_code is not a string"); return false; }
if (!PyUnicode_Check(dest->end_code)) { PyErr_SetString(PyExc_TypeError, "end_code is not a string"); return false; }
#undef A
#undef I
return true;
}
static bool
next_segment(SegmentPointer *s, PyObject *highlights) {
if (s->pos < s->num) {
if (!convert_segment(PyList_GET_ITEM(highlights, s->pos), &s->sg)) return false;
s->pos++;
} else s->sg.current_pos = UINT_MAX;
return true;
}
typedef struct LineBuffer {
Py_UCS4 *buf;
size_t pos, capacity;
} LineBuffer;
static bool
ensure_space(LineBuffer *lb, size_t num) {
if (lb->pos + num >= lb->capacity) {
size_t new_cap = MAX(lb->capacity * 2, 4096u);
new_cap = MAX(lb->pos + num + 1024u, new_cap);
lb->buf = realloc(lb->buf, new_cap * sizeof(lb->buf[0]));
if (!lb->buf) { PyErr_NoMemory(); return false; }
lb->capacity = new_cap;
}
return true;
}
static bool
insert_code(PyObject *code, LineBuffer *lb) {
unsigned int csz = PyUnicode_GET_LENGTH(code);
if (!ensure_space(lb, csz)) return false;
for (unsigned int s = 0; s < csz; s++) lb->buf[lb->pos++] = PyUnicode_READ(PyUnicode_KIND(code), PyUnicode_DATA(code), s);
return true;
}
static bool
add_line(Segment *bg_segment, Segment *fg_segment, LineBuffer *lb, PyObject *ans) {
bool bg_is_active = bg_segment->current_pos == bg_segment->end_pos, fg_is_active = fg_segment->current_pos == fg_segment->end_pos;
if (bg_is_active) { if(!insert_code(bg_segment->end_code, lb)) return false; }
if (fg_is_active) { if(!insert_code(fg_segment->end_code, lb)) return false; }
PyObject *wl = PyUnicode_FromKindAndData(PyUnicode_4BYTE_KIND, lb->buf, lb->pos);
if (!wl) return false;
int ret = PyList_Append(ans, wl); Py_DECREF(wl); if (ret != 0) return false;
lb->pos = 0;
if (bg_is_active) { if(!insert_code(bg_segment->start_code, lb)) return false; }
if (fg_is_active) { if(!insert_code(fg_segment->start_code, lb)) return false; }
return true;
}
static LineBuffer line_buffer;
static PyObject*
split_with_highlights(PyObject *self UNUSED, PyObject *args) {
PyObject *line, *truncate_points_py, *fg_highlights, *bg_highlight;
if (!PyArg_ParseTuple(args, "UO!O!O", &line, &PyList_Type, &truncate_points_py, &PyList_Type, &fg_highlights, &bg_highlight)) return NULL;
PyObject *ans = PyList_New(0);
if (!ans) return NULL;
static unsigned int truncate_points[256];
unsigned int num_truncate_pts = PyList_GET_SIZE(truncate_points_py), truncate_pos = 0, truncate_point;
for (unsigned int i = 0; i < MIN(num_truncate_pts, arraysz(truncate_points)); i++) {
truncate_points[i] = PyLong_AsUnsignedLong(PyList_GET_ITEM(truncate_points_py, i));
}
SegmentPointer fg_segment = { .sg = EMPTY_SEGMENT, .num = PyList_GET_SIZE(fg_highlights)}, bg_segment = { .sg = EMPTY_SEGMENT };
if (bg_highlight != Py_None) { if (!convert_segment(bg_highlight, &bg_segment.sg)) { Py_CLEAR(ans); return NULL; }; bg_segment.num = 1; }
#define CHECK_CALL(func, ...) if (!func(__VA_ARGS__)) { Py_CLEAR(ans); if (!PyErr_Occurred()) PyErr_SetString(PyExc_ValueError, "unknown error while processing line"); return NULL; }
CHECK_CALL(next_segment, &fg_segment, fg_highlights);
#define NEXT_TRUNCATE_POINT truncate_point = (truncate_pos < num_truncate_pts) ? truncate_points[truncate_pos++] : UINT_MAX
NEXT_TRUNCATE_POINT;
#define INSERT_CODE(x) { CHECK_CALL(insert_code, x, &line_buffer); }
#define ADD_LINE CHECK_CALL(add_line, &bg_segment.sg, &fg_segment.sg, &line_buffer, ans);
#define ADD_CHAR(x) { \
if (!ensure_space(&line_buffer, 1)) { Py_CLEAR(ans); return NULL; } \
line_buffer.buf[line_buffer.pos++] = x; \
}
#define CHECK_SEGMENT(sgp, is_fg) { \
if (i == sgp.sg.current_pos) { \
INSERT_CODE(sgp.sg.current_pos == sgp.sg.start_pos ? sgp.sg.start_code : sgp.sg.end_code); \
if (sgp.sg.current_pos == sgp.sg.start_pos) sgp.sg.current_pos = sgp.sg.end_pos; \
else { \
if (is_fg) { \
CHECK_CALL(next_segment, &fg_segment, fg_highlights); \
if (sgp.sg.current_pos == i) { \
INSERT_CODE(sgp.sg.start_code); \
sgp.sg.current_pos = sgp.sg.end_pos; \
} \
} else sgp.sg.current_pos = UINT_MAX; \
} \
}\
}
const unsigned int line_sz = PyUnicode_GET_LENGTH(line);
line_buffer.pos = 0;
unsigned int i = 0;
for (; i < line_sz; i++) {
if (i == truncate_point) { ADD_LINE; NEXT_TRUNCATE_POINT; }
CHECK_SEGMENT(bg_segment, false);
CHECK_SEGMENT(fg_segment, true)
ADD_CHAR(PyUnicode_READ(PyUnicode_KIND(line), PyUnicode_DATA(line), i));
}
if (line_buffer.pos) ADD_LINE;
return ans;
#undef INSERT_CODE
#undef CHECK_SEGMENT
#undef CHECK_CALL
#undef ADD_CHAR
#undef ADD_LINE
#undef NEXT_TRUNCATE_POINT
}
static PyObject*
splitlines_like_git(PyObject *self UNUSED, PyObject *args) {
char *raw; Py_ssize_t sz;
PyObject *callback;
if (!PyArg_ParseTuple(args, "y#O", &raw, &sz, &callback)) return NULL;
while (sz > 0 && (raw[sz-1] == '\n' || raw[sz-1] == '\r')) sz--;
PyObject *mv, *ret;
#define CALLBACK \
mv = PyMemoryView_FromMemory(raw + start, i - start, PyBUF_READ); \
if (mv == NULL) return NULL; \
ret = PyObject_CallFunctionObjArgs(callback, mv, NULL); \
Py_DECREF(mv); \
if (ret == NULL) return NULL; \
Py_DECREF(ret); start = i + 1;
Py_ssize_t i = 0, start = 0;
for (; i < sz; i++) {
switch (raw[i]) {
case '\n':
CALLBACK; break;
case '\r':
CALLBACK;
if (i + 1 < sz && raw[i+1] == '\n') { i++; start++; }
break;
}
}
if (start < sz) {
i = sz; CALLBACK;
}
Py_RETURN_NONE;
}
static void
free_resources(void) {
free(line_buffer.buf); line_buffer.buf = NULL; line_buffer.capacity = 0; line_buffer.pos = 0;
}
static PyMethodDef module_methods[] = {
{"changed_center", (PyCFunction)changed_center, METH_VARARGS, ""},
{"split_with_highlights", (PyCFunction)split_with_highlights, METH_VARARGS, ""},
{"splitlines_like_git", (PyCFunction)splitlines_like_git, METH_VARARGS, ""},
{NULL, NULL, 0, NULL} /* Sentinel */
};
static struct PyModuleDef module = {
.m_base = PyModuleDef_HEAD_INIT,
.m_name = "diff_speedup", /* name of module */
.m_doc = NULL,
.m_size = -1,
.m_methods = module_methods
};
EXPORTED PyMODINIT_FUNC
PyInit_diff_speedup(void) {
PyObject *m;
m = PyModule_Create(&module);
if (m == NULL) return NULL;
Py_AtExit(free_resources);
return m;
}

View File

@ -27,9 +27,8 @@ class TestBuild(BaseTest):
def test_loading_extensions(self) -> None:
import kitty.fast_data_types as fdt
from kittens.diff import diff_speedup
from kittens.transfer import rsync
del fdt, diff_speedup, rsync
del fdt, rsync
def test_loading_shaders(self) -> None:
from kitty.utils import load_shaders
@ -79,12 +78,6 @@ class TestBuild(BaseTest):
c = ssl.create_default_context()
self.assertGreater(c.cert_store_stats()['x509_ca'], 2)
def test_pygments(self):
if not getattr(sys, 'frozen', False):
self.skipTest('Pygments is only tested on frozen builds')
import pygments
del pygments
def test_docs_url(self):
from kitty.constants import website_url
from kitty.utils import docs_url