1#!/usr/local/bin/python3.8 2# vim:fileencoding=utf-8 3# License: GPL v3 Copyright: 2018, Kovid Goyal <kovid at kovidgoyal.net> 4 5import warnings 6from gettext import gettext as _ 7from itertools import repeat, zip_longest 8from math import ceil 9from typing import Callable, Dict, Generator, Iterable, List, Optional, Tuple 10 11from kitty.cli_stub import DiffCLIOptions 12from kitty.fast_data_types import truncate_point_for_length, wcswidth 13from kitty.types import run_once 14from kitty.utils import ScreenSize 15 16from ..tui.images import ImageManager, can_display_images 17from .collect import ( 18 Collection, Segment, data_for_path, highlights_for_path, is_image, 19 lines_for_path, path_name_map, sanitize 20) 21from .config import formats 22from .diff_speedup import split_with_highlights as _split_with_highlights 23from .patch import Chunk, Hunk, Patch 24 25 26class ImageSupportWarning(Warning): 27 pass 28 29 30@run_once 31def images_supported() -> bool: 32 ans = can_display_images() 33 if not ans: 34 warnings.warn('ImageMagick not found images cannot be displayed', ImageSupportWarning) 35 return ans 36 37 38class Ref: 39 40 def __setattr__(self, name: str, value: object) -> None: 41 raise AttributeError("can't set attribute") 42 43 def __repr__(self) -> str: 44 return '{}({})'.format(self.__class__.__name__, ', '.join( 45 '{}={}'.format(n, getattr(self, n)) for n in self.__slots__ if n != '_hash')) 46 47 48class LineRef(Ref): 49 50 __slots__ = ('src_line_number', 'wrapped_line_idx') 51 src_line_number: int 52 wrapped_line_idx: int 53 54 def __init__(self, sln: int, wli: int = 0) -> None: 55 object.__setattr__(self, 'src_line_number', sln) 56 object.__setattr__(self, 'wrapped_line_idx', wli) 57 58 59class Reference(Ref): 60 61 __slots__ = ('path', 'extra') 62 path: str 63 extra: Optional[LineRef] 64 65 def __init__(self, path: str, extra: Optional[LineRef] = None) -> None: 66 object.__setattr__(self, 'path', path) 67 object.__setattr__(self, 'extra', extra) 68 69 70class Line: 71 72 __slots__ = ('text', 'ref', 'is_change_start', 'image_data') 73 74 def __init__( 75 self, 76 text: str, 77 ref: Reference, 78 change_start: bool = False, 79 image_data: Optional[Tuple[Optional['ImagePlacement'], Optional['ImagePlacement']]] = None 80 ) -> None: 81 self.text = text 82 self.ref = ref 83 self.is_change_start = change_start 84 self.image_data = image_data 85 86 87def yield_lines_from(iterator: Iterable[str], reference: Reference, is_change_start: bool = True) -> Generator[Line, None, None]: 88 for text in iterator: 89 yield Line(text, reference, is_change_start) 90 is_change_start = False 91 92 93def human_readable(size: int, sep: str = ' ') -> str: 94 """ Convert a size in bytes into a human readable form """ 95 divisor, suffix = 1, "B" 96 for i, candidate in enumerate(('B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB')): 97 if size < (1 << ((i + 1) * 10)): 98 divisor, suffix = (1 << (i * 10)), candidate 99 break 100 s = str(float(size)/divisor) 101 if s.find(".") > -1: 102 s = s[:s.find(".")+2] 103 if s.endswith('.0'): 104 s = s[:-2] 105 return s + sep + suffix 106 107 108def fit_in(text: str, count: int) -> str: 109 p = truncate_point_for_length(text, count) 110 if p >= len(text): 111 return text 112 if count > 1: 113 p = truncate_point_for_length(text, count - 1) 114 return text[:p] + '…' 115 116 117def fill_in(text: str, sz: int) -> str: 118 w = wcswidth(text) 119 if w < sz: 120 text += ' ' * (sz - w) 121 return text 122 123 124def place_in(text: str, sz: int) -> str: 125 return fill_in(fit_in(text, sz), sz) 126 127 128def format_func(which: str) -> Callable[[str], str]: 129 def formatted(text: str) -> str: 130 fmt = formats[which] 131 return '\x1b[' + fmt + 'm' + text + '\x1b[0m' 132 formatted.__name__ = which + '_format' 133 return formatted 134 135 136text_format = format_func('text') 137title_format = format_func('title') 138margin_format = format_func('margin') 139added_format = format_func('added') 140removed_format = format_func('removed') 141removed_margin_format = format_func('removed_margin') 142added_margin_format = format_func('added_margin') 143filler_format = format_func('filler') 144margin_filler_format = format_func('margin_filler') 145hunk_margin_format = format_func('hunk_margin') 146hunk_format = format_func('hunk') 147highlight_map = {'remove': ('removed_highlight', 'removed'), 'add': ('added_highlight', 'added')} 148 149 150def highlight_boundaries(ltype: str) -> Tuple[str, str]: 151 s, e = highlight_map[ltype] 152 start = '\x1b[' + formats[s] + 'm' 153 stop = '\x1b[' + formats[e] + 'm' 154 return start, stop 155 156 157def title_lines(left_path: Optional[str], right_path: Optional[str], args: DiffCLIOptions, columns: int, margin_size: int) -> Generator[str, None, None]: 158 m = ' ' * margin_size 159 left_name = path_name_map.get(left_path) if left_path else None 160 right_name = path_name_map.get(right_path) if right_path else None 161 if right_name and right_name != left_name: 162 n1 = fit_in(m + sanitize(left_name or ''), columns // 2 - margin_size) 163 n1 = place_in(n1, columns // 2) 164 n2 = fit_in(m + sanitize(right_name), columns // 2 - margin_size) 165 n2 = place_in(n2, columns // 2) 166 name = n1 + n2 167 else: 168 name = place_in(m + sanitize(left_name or ''), columns) 169 yield title_format(place_in(name, columns)) 170 yield title_format('━' * columns) 171 172 173def binary_lines(path: Optional[str], other_path: Optional[str], columns: int, margin_size: int) -> Generator[str, None, None]: 174 template = _('Binary file: {}') 175 available_cols = columns // 2 - margin_size 176 177 def fl(path: str, fmt: Callable[[str], str]) -> str: 178 text = template.format(human_readable(len(data_for_path(path)))) 179 text = place_in(text, available_cols) 180 return margin_format(' ' * margin_size) + fmt(text) 181 182 if path is None: 183 filler = render_diff_line('', '', 'filler', margin_size, available_cols) 184 assert other_path is not None 185 yield filler + fl(other_path, added_format) 186 elif other_path is None: 187 filler = render_diff_line('', '', 'filler', margin_size, available_cols) 188 yield fl(path, removed_format) + filler 189 else: 190 yield fl(path, removed_format) + fl(other_path, added_format) 191 192 193def split_to_size(line: str, width: int) -> Generator[str, None, None]: 194 if not line: 195 yield line 196 while line: 197 p = truncate_point_for_length(line, width) 198 yield line[:p] 199 line = line[p:] 200 201 202def truncate_points(line: str, width: int) -> Generator[int, None, None]: 203 pos = 0 204 sz = len(line) 205 while True: 206 pos = truncate_point_for_length(line, width, pos) 207 if pos < sz: 208 yield pos 209 else: 210 break 211 212 213def split_with_highlights(line: str, width: int, highlights: List, bg_highlight: Optional[Segment] = None) -> List: 214 truncate_pts = list(truncate_points(line, width)) 215 return _split_with_highlights(line, truncate_pts, highlights, bg_highlight) 216 217 218margin_bg_map = {'filler': margin_filler_format, 'remove': removed_margin_format, 'add': added_margin_format, 'context': margin_format} 219text_bg_map = {'filler': filler_format, 'remove': removed_format, 'add': added_format, 'context': text_format} 220 221 222class DiffData: 223 224 def __init__(self, left_path: str, right_path: str, available_cols: int, margin_size: int): 225 self.left_path, self.right_path = left_path, right_path 226 self.available_cols = available_cols 227 self.margin_size = margin_size 228 self.left_lines, self.right_lines = map(lines_for_path, (left_path, right_path)) 229 self.filler_line = render_diff_line('', '', 'filler', margin_size, available_cols) 230 self.left_filler_line = render_diff_line('', '', 'remove', margin_size, available_cols) 231 self.right_filler_line = render_diff_line('', '', 'add', margin_size, available_cols) 232 self.left_hdata = highlights_for_path(left_path) 233 self.right_hdata = highlights_for_path(right_path) 234 235 def left_highlights_for_line(self, line_num: int) -> List[Segment]: 236 if line_num < len(self.left_hdata): 237 return self.left_hdata[line_num] 238 return [] 239 240 def right_highlights_for_line(self, line_num: int) -> List[Segment]: 241 if line_num < len(self.right_hdata): 242 return self.right_hdata[line_num] 243 return [] 244 245 246def render_diff_line(number: Optional[str], text: str, ltype: str, margin_size: int, available_cols: int) -> str: 247 margin = margin_bg_map[ltype](place_in(number or '', margin_size)) 248 content = text_bg_map[ltype](fill_in(text or '', available_cols)) 249 return margin + content 250 251 252def render_diff_pair( 253 left_line_number: Optional[str], left: str, left_is_change: bool, 254 right_line_number: Optional[str], right: str, right_is_change: bool, 255 is_first: bool, margin_size: int, available_cols: int 256) -> str: 257 ltype = 'filler' if left_line_number is None else ('remove' if left_is_change else 'context') 258 rtype = 'filler' if right_line_number is None else ('add' if right_is_change else 'context') 259 return ( 260 render_diff_line(left_line_number if is_first else None, left, ltype, margin_size, available_cols) + 261 render_diff_line(right_line_number if is_first else None, right, rtype, margin_size, available_cols) 262 ) 263 264 265def hunk_title(hunk_num: int, hunk: Hunk, margin_size: int, available_cols: int) -> str: 266 m = hunk_margin_format(' ' * margin_size) 267 t = '@@ -{},{} +{},{} @@ {}'.format(hunk.left_start + 1, hunk.left_count, hunk.right_start + 1, hunk.right_count, hunk.title) 268 return m + hunk_format(place_in(t, available_cols)) 269 270 271def render_half_line( 272 line_number: int, 273 line: str, 274 highlights: List, 275 ltype: str, 276 margin_size: int, 277 available_cols: int, 278 changed_center: Optional[Tuple[int, int]] = None 279) -> Generator[str, None, None]: 280 bg_highlight: Optional[Segment] = None 281 if changed_center is not None and changed_center[0]: 282 prefix_count, suffix_count = changed_center 283 line_sz = len(line) 284 if prefix_count + suffix_count < line_sz: 285 start, stop = highlight_boundaries(ltype) 286 seg = Segment(prefix_count, start) 287 seg.end = line_sz - suffix_count 288 seg.end_code = stop 289 bg_highlight = seg 290 if highlights or bg_highlight: 291 lines: Iterable[str] = split_with_highlights(line, available_cols, highlights, bg_highlight) 292 else: 293 lines = split_to_size(line, available_cols) 294 lnum = str(line_number + 1) 295 for line in lines: 296 yield render_diff_line(lnum, line, ltype, margin_size, available_cols) 297 lnum = '' 298 299 300def lines_for_chunk(data: DiffData, hunk_num: int, chunk: Chunk, chunk_num: int) -> Generator[Line, None, None]: 301 if chunk.is_context: 302 for i in range(chunk.left_count): 303 left_line_number = line_ref = chunk.left_start + i 304 right_line_number = chunk.right_start + i 305 highlights = data.left_highlights_for_line(left_line_number) 306 if highlights: 307 lines: Iterable[str] = split_with_highlights(data.left_lines[left_line_number], data.available_cols, highlights) 308 else: 309 lines = split_to_size(data.left_lines[left_line_number], data.available_cols) 310 left_line_number_s = str(left_line_number + 1) 311 right_line_number_s = str(right_line_number + 1) 312 for wli, text in enumerate(lines): 313 line = render_diff_line(left_line_number_s, text, 'context', data.margin_size, data.available_cols) 314 if right_line_number_s == left_line_number_s: 315 r = line 316 else: 317 r = render_diff_line(right_line_number_s, text, 'context', data.margin_size, data.available_cols) 318 ref = Reference(data.left_path, LineRef(line_ref, wli)) 319 yield Line(line + r, ref) 320 left_line_number_s = right_line_number_s = '' 321 else: 322 common = min(chunk.left_count, chunk.right_count) 323 for i in range(max(chunk.left_count, chunk.right_count)): 324 ll: List[str] = [] 325 rl: List[str] = [] 326 if i < chunk.left_count: 327 rln = ref_ln = chunk.left_start + i 328 ll.extend(render_half_line( 329 rln, data.left_lines[rln], data.left_highlights_for_line(rln), 330 'remove', data.margin_size, data.available_cols, 331 None if chunk.centers is None else chunk.centers[i])) 332 ref_path = data.left_path 333 if i < chunk.right_count: 334 rln = ref_ln = chunk.right_start + i 335 rl.extend(render_half_line( 336 rln, data.right_lines[rln], data.right_highlights_for_line(rln), 337 'add', data.margin_size, data.available_cols, 338 None if chunk.centers is None else chunk.centers[i])) 339 ref_path = data.right_path 340 if i < common: 341 extra = len(ll) - len(rl) 342 if extra != 0: 343 if extra < 0: 344 x, fl = ll, data.left_filler_line 345 extra = -extra 346 else: 347 x, fl = rl, data.right_filler_line 348 x.extend(repeat(fl, extra)) 349 else: 350 if ll: 351 x, count = rl, len(ll) 352 else: 353 x, count = ll, len(rl) 354 x.extend(repeat(data.filler_line, count)) 355 for wli, (left_line, right_line) in enumerate(zip(ll, rl)): 356 ref = Reference(ref_path, LineRef(ref_ln, wli)) 357 yield Line(left_line + right_line, ref, i == 0 and wli == 0) 358 359 360def lines_for_diff(left_path: str, right_path: str, hunks: Iterable[Hunk], args: DiffCLIOptions, columns: int, margin_size: int) -> Generator[Line, None, None]: 361 available_cols = columns // 2 - margin_size 362 data = DiffData(left_path, right_path, available_cols, margin_size) 363 364 for hunk_num, hunk in enumerate(hunks): 365 yield Line(hunk_title(hunk_num, hunk, margin_size, columns - margin_size), Reference(left_path, LineRef(hunk.left_start))) 366 for cnum, chunk in enumerate(hunk.chunks): 367 yield from lines_for_chunk(data, hunk_num, chunk, cnum) 368 369 370def all_lines(path: str, args: DiffCLIOptions, columns: int, margin_size: int, is_add: bool = True) -> Generator[Line, None, None]: 371 available_cols = columns // 2 - margin_size 372 ltype = 'add' if is_add else 'remove' 373 lines = lines_for_path(path) 374 filler = render_diff_line('', '', 'filler', margin_size, available_cols) 375 msg_written = False 376 hdata = highlights_for_path(path) 377 378 def highlights(num: int) -> List[Segment]: 379 return hdata[num] if num < len(hdata) else [] 380 381 for line_number, line in enumerate(lines): 382 h = render_half_line(line_number, line, highlights(line_number), ltype, margin_size, available_cols) 383 for i, hl in enumerate(h): 384 ref = Reference(path, LineRef(line_number, i)) 385 empty = filler 386 if not msg_written: 387 msg_written = True 388 empty = render_diff_line( 389 '', _('This file was added') if is_add else _('This file was removed'), 390 'filler', margin_size, available_cols) 391 text = (empty + hl) if is_add else (hl + empty) 392 yield Line(text, ref, line_number == 0 and i == 0) 393 394 395def rename_lines(path: str, other_path: str, args: DiffCLIOptions, columns: int, margin_size: int) -> Generator[str, None, None]: 396 m = ' ' * margin_size 397 for line in split_to_size(_('The file {0} was renamed to {1}').format( 398 sanitize(path_name_map[path]), sanitize(path_name_map[other_path])), columns - margin_size): 399 yield m + line 400 401 402class Image: 403 404 def __init__(self, image_id: int, width: int, height: int, margin_size: int, screen_size: ScreenSize) -> None: 405 self.image_id = image_id 406 self.width, self.height = width, height 407 self.rows = int(ceil(self.height / screen_size.cell_height)) 408 self.columns = int(ceil(self.width / screen_size.cell_width)) 409 self.margin_size = margin_size 410 411 412class ImagePlacement: 413 414 def __init__(self, image: Image, row: int) -> None: 415 self.image = image 416 self.row = row 417 418 419def render_image( 420 path: str, 421 is_left: bool, 422 available_cols: int, margin_size: int, 423 image_manager: ImageManager 424) -> Generator[Tuple[str, Reference, Optional[ImagePlacement]], None, None]: 425 lnum = 0 426 margin_fmt = removed_margin_format if is_left else added_margin_format 427 m = margin_fmt(' ' * margin_size) 428 fmt = removed_format if is_left else added_format 429 430 def yield_split(text: str) -> Generator[Tuple[str, Reference, Optional[ImagePlacement]], None, None]: 431 nonlocal lnum 432 for i, line in enumerate(split_to_size(text, available_cols)): 433 yield m + fmt(place_in(line, available_cols)), Reference(path, LineRef(lnum, i)), None 434 lnum += 1 435 436 try: 437 image_id, width, height = image_manager.send_image(path, available_cols - margin_size, image_manager.screen_size.rows - 2) 438 except Exception as e: 439 yield from yield_split(_('Failed to render image, with error:')) 440 yield from yield_split(' '.join(str(e).splitlines())) 441 return 442 meta = _('Dimensions: {0}x{1} pixels Size: {2}').format( 443 width, height, human_readable(len(data_for_path(path)))) 444 yield from yield_split(meta) 445 bg_line = m + fmt(' ' * available_cols) 446 img = Image(image_id, width, height, margin_size, image_manager.screen_size) 447 for r in range(img.rows): 448 yield bg_line, Reference(path, LineRef(lnum)), ImagePlacement(img, r) 449 lnum += 1 450 451 452def image_lines( 453 left_path: Optional[str], 454 right_path: Optional[str], 455 columns: int, 456 margin_size: int, 457 image_manager: ImageManager 458) -> Generator[Line, None, None]: 459 available_cols = columns // 2 - margin_size 460 left_lines: Iterable[Tuple[str, Reference, Optional[ImagePlacement]]] = iter(()) 461 right_lines: Iterable[Tuple[str, Reference, Optional[ImagePlacement]]] = iter(()) 462 if left_path is not None: 463 left_lines = render_image(left_path, True, available_cols, margin_size, image_manager) 464 if right_path is not None: 465 right_lines = render_image(right_path, False, available_cols, margin_size, image_manager) 466 filler = ' ' * (available_cols + margin_size) 467 is_change_start = True 468 for left, right in zip_longest(left_lines, right_lines): 469 left_placement = right_placement = None 470 if left is None: 471 left = filler 472 right, ref, right_placement = right 473 elif right is None: 474 right = filler 475 left, ref, left_placement = left 476 else: 477 right, ref, right_placement = right 478 left, ref, left_placement = left 479 image_data = (left_placement, right_placement) if left_placement or right_placement else None 480 yield Line(left + right, ref, is_change_start, image_data) 481 is_change_start = False 482 483 484class RenderDiff: 485 486 margin_size: int = 0 487 488 def __call__( 489 self, 490 collection: Collection, 491 diff_map: Dict[str, Patch], 492 args: DiffCLIOptions, 493 columns: int, 494 image_manager: ImageManager 495 ) -> Generator[Line, None, None]: 496 largest_line_number = 0 497 for path, item_type, other_path in collection: 498 if item_type == 'diff': 499 patch = diff_map.get(path) 500 if patch is not None: 501 largest_line_number = max(largest_line_number, patch.largest_line_number) 502 503 margin_size = self.margin_size = max(3, len(str(largest_line_number)) + 1) 504 last_item_num = len(collection) - 1 505 506 for i, (path, item_type, other_path) in enumerate(collection): 507 item_ref = Reference(path) 508 is_binary = isinstance(data_for_path(path), bytes) 509 if not is_binary and item_type == 'diff' and isinstance(data_for_path(other_path), bytes): 510 is_binary = True 511 is_img = is_binary and (is_image(path) or is_image(other_path)) and images_supported() 512 yield from yield_lines_from(title_lines(path, other_path, args, columns, margin_size), item_ref, False) 513 if item_type == 'diff': 514 if is_binary: 515 if is_img: 516 ans = image_lines(path, other_path, columns, margin_size, image_manager) 517 else: 518 ans = yield_lines_from(binary_lines(path, other_path, columns, margin_size), item_ref) 519 else: 520 assert other_path is not None 521 ans = lines_for_diff(path, other_path, diff_map[path], args, columns, margin_size) 522 elif item_type == 'add': 523 if is_binary: 524 if is_img: 525 ans = image_lines(None, path, columns, margin_size, image_manager) 526 else: 527 ans = yield_lines_from(binary_lines(None, path, columns, margin_size), item_ref) 528 else: 529 ans = all_lines(path, args, columns, margin_size, is_add=True) 530 elif item_type == 'removal': 531 if is_binary: 532 if is_img: 533 ans = image_lines(path, None, columns, margin_size, image_manager) 534 else: 535 ans = yield_lines_from(binary_lines(path, None, columns, margin_size), item_ref) 536 else: 537 ans = all_lines(path, args, columns, margin_size, is_add=False) 538 elif item_type == 'rename': 539 assert other_path is not None 540 ans = yield_lines_from(rename_lines(path, other_path, args, columns, margin_size), item_ref) 541 else: 542 raise ValueError('Unsupported item type: {}'.format(item_type)) 543 yield from ans 544 if i < last_item_num: 545 yield Line('', item_ref) 546 547 548render_diff = RenderDiff() 549