diff --git a/tools/cmd/diff/render.go b/tools/cmd/diff/render.go index 4b20eaf01..bc2a4f27a 100644 --- a/tools/cmd/diff/render.go +++ b/tools/cmd/diff/render.go @@ -3,13 +3,17 @@ package diff import ( + "errors" "fmt" + "math" + "strconv" + "strings" + + "kitty/tools/tui/graphics" "kitty/tools/tui/sgr" "kitty/tools/utils" "kitty/tools/utils/style" "kitty/tools/wcswidth" - "strconv" - "strings" ) var _ = fmt.Print @@ -31,10 +35,15 @@ type Reference struct { } type LogicalLine struct { - src Reference - line_type LineType - screen_lines []string - is_change_start bool + src Reference + line_type LineType + screen_lines []string + is_change_start bool + left_image, right_image struct { + key string + count int + } + image_lines_offset int } func (self *LogicalLine) IncrementScrollPosBy(pos *ScrollPos, amt int) (delta int) { @@ -248,7 +257,8 @@ func render_diff_line(number, text, ltype string, margin_size int, available_col return margin + content } -func image_lines(left_path, right_path string, columns, margin_size int, ans []*LogicalLine) ([]*LogicalLine, error) { +func image_lines(left_path, right_path string, screen_size screen_size, margin_size int, image_size graphics.Size, ans []*LogicalLine) ([]*LogicalLine, error) { + columns := screen_size.columns available_cols := columns/2 - margin_size ll, err := first_binary_line(left_path, right_path, columns, margin_size, func(path string, formatter func(...any) string) (string, error) { sz, err := size_for_path(path) @@ -267,6 +277,44 @@ func image_lines(left_path, right_path string, columns, margin_size int, ans []* if err != nil { return nil, err } + ll.image_lines_offset = len(ll.screen_lines) + + do_side := func(path string, filler string) []string { + if path == "" { + return nil + } + sz, err := image_collection.GetSizeIfAvailable(path, image_size) + if err == nil { + count := int(math.Ceil(float64(sz.Height) / float64(screen_size.cell_height))) + return utils.Repeat(filler, count) + } + if errors.Is(err, graphics.ErrNotFound) { + return style.WrapTextAsLines("Loading image...", "", available_cols) + } + return style.WrapTextAsLines(fmt.Sprintf("Failed to load image: %s", err), "", available_cols) + } + left_lines := do_side(left_path, removed_format(strings.Repeat(` `, available_cols))) + if ll.left_image.count = len(left_lines); ll.left_image.count > 0 { + ll.left_image.key = left_path + } + right_lines := do_side(right_path, added_format(strings.Repeat(` `, available_cols))) + if ll.right_image.count = len(right_lines); ll.right_image.count > 0 { + ll.right_image.key = right_path + } + filler := filler_format(strings.Repeat(` `, available_cols)) + m := strings.Repeat(` `, margin_size) + get_line := func(i int, which []string, margin_fmt func(...any) string) string { + if i < len(which) { + return margin_fmt(m) + which[i] + } + return margin_filler_format(m) + filler + } + for i := 0; i < utils.Min(len(left_lines), len(right_lines)); i++ { + left, right := get_line(i, left_lines, removed_margin_format), get_line(i, right_lines, added_margin_format) + ll.screen_lines = append(ll.screen_lines, left+right) + } + + ll.line_type = IMAGE_LINE return append(ans, ll), nil } @@ -298,7 +346,11 @@ func first_binary_line(left_path, right_path string, columns, margin_size int, r } line = l + r } - ll := LogicalLine{is_change_start: true, line_type: CHANGE_LINE, src: Reference{path: left_path, linenum: 0}, screen_lines: []string{line}} + ref := left_path + if ref == "" { + ref = right_path + } + ll := LogicalLine{is_change_start: true, line_type: CHANGE_LINE, src: Reference{path: ref, linenum: 0}, screen_lines: []string{line}} if left_path == "" { ll.src.path = right_path } @@ -519,20 +571,11 @@ func rename_lines(path, other_path string, columns, margin_size int, ans []*Logi return append(ans, &ll), nil } -func render(collection *Collection, diff_map map[string]*Patch, columns int) (result *LogicalLines, err error) { - largest_line_number := 0 - collection.Apply(func(path, typ, changed_path string) error { - if typ == "diff" { - patch := diff_map[path] - if patch != nil { - largest_line_number = utils.Max(largest_line_number, patch.largest_line_number) - } - } - return nil - }) +func render(collection *Collection, diff_map map[string]*Patch, screen_size screen_size, largest_line_number int, image_size graphics.Size) (result *LogicalLines, err error) { margin_size := utils.Max(3, len(strconv.Itoa(largest_line_number))+1) ans := make([]*LogicalLine, 0, 1024) empty_line := LogicalLine{line_type: EMPTY_LINE} + columns := screen_size.columns err = collection.Apply(func(path, item_type, changed_path string) error { ans = title_lines(path, changed_path, columns, margin_size, ans) defer func() { @@ -550,7 +593,7 @@ func render(collection *Collection, diff_map map[string]*Patch, columns int) (re case "diff": if is_binary { if is_img { - ans, err = image_lines(path, changed_path, columns, margin_size, ans) + ans, err = image_lines(path, changed_path, screen_size, margin_size, image_size, ans) } else { ans, err = binary_lines(path, changed_path, columns, margin_size, ans) } @@ -563,7 +606,7 @@ func render(collection *Collection, diff_map map[string]*Patch, columns int) (re case "add": if is_binary { if is_img { - ans, err = image_lines("", path, columns, margin_size, ans) + ans, err = image_lines("", path, screen_size, margin_size, image_size, ans) } else { ans, err = binary_lines("", path, columns, margin_size, ans) } @@ -576,7 +619,7 @@ func render(collection *Collection, diff_map map[string]*Patch, columns int) (re case "removal": if is_binary { if is_img { - ans, err = image_lines(path, "", columns, margin_size, ans) + ans, err = image_lines(path, "", screen_size, margin_size, image_size, ans) } else { ans, err = binary_lines(path, "", columns, margin_size, ans) } diff --git a/tools/cmd/diff/ui.go b/tools/cmd/diff/ui.go index a84b4c18f..bb576fc4c 100644 --- a/tools/cmd/diff/ui.go +++ b/tools/cmd/diff/ui.go @@ -26,6 +26,7 @@ const ( DIFF HIGHLIGHT IMAGE_LOAD + IMAGE_RESIZE ) type ScrollPos struct { @@ -45,10 +46,12 @@ type AsyncResult struct { rtype ResultType collection *Collection diff_map map[string]*Patch + page_size graphics.Size } var image_collection *graphics.ImageCollection +type screen_size struct{ rows, columns, num_lines, cell_width, cell_height int } type Handler struct { async_results chan AsyncResult shortcut_tracker config.ShortcutTracker @@ -59,7 +62,7 @@ type Handler struct { lp *loop.Loop current_context_count, original_context_count int added_count, removed_count int - screen_size struct{ rows, columns, num_lines int } + screen_size screen_size scroll_pos, max_scroll_pos ScrollPos restore_position *ScrollPos inputting_command bool @@ -67,18 +70,30 @@ type Handler struct { rl *readline.Readline current_search *Search current_search_is_regex, current_search_is_backward bool + largest_line_number int + images_resized_to graphics.Size } func (self *Handler) calculate_statistics() { self.added_count, self.removed_count = self.collection.added_count, self.collection.removed_count + self.largest_line_number = 0 for _, patch := range self.diff_map { self.added_count += patch.added_count self.removed_count += patch.removed_count + self.largest_line_number = utils.Max(patch.largest_line_number, self.largest_line_number) } } var DebugPrintln = tty.DebugPrintln +func (self *Handler) update_screen_size(sz loop.ScreenSize) { + self.screen_size.rows = int(sz.HeightCells) + self.screen_size.columns = int(sz.WidthCells) + self.screen_size.num_lines = self.screen_size.rows - 1 + self.screen_size.cell_height = int(sz.CellHeight) + self.screen_size.cell_width = int(sz.CellWidth) +} + func (self *Handler) initialize() { self.rl = readline.New(self.lp, readline.RlInit{DontMarkPrompts: true, Prompt: "/"}) image_collection = graphics.NewImageCollection() @@ -87,9 +102,7 @@ func (self *Handler) initialize() { self.current_context_count = int(conf.Num_context_lines) } sz, _ := self.lp.ScreenSize() - self.screen_size.rows = int(sz.HeightCells) - self.screen_size.columns = int(sz.WidthCells) - self.screen_size.num_lines = self.screen_size.rows - 1 + self.update_screen_size(sz) self.original_context_count = self.current_context_count self.lp.SetDefaultColor(loop.FOREGROUND, conf.Foreground) self.lp.SetDefaultColor(loop.CURSOR, conf.Foreground) @@ -174,6 +187,27 @@ func (self *Handler) load_all_images() { }() } +func (self *Handler) resize_all_images_if_needed() { + if self.logical_lines == nil { + return + } + margin_size := self.logical_lines.margin_size + columns := self.logical_lines.columns + available_cols := columns/2 - margin_size + sz := graphics.Size{ + Width: available_cols * self.screen_size.cell_width, + Height: self.screen_size.num_lines * 2 * self.screen_size.cell_height, + } + if sz != self.images_resized_to { + go func() { + image_collection.ResizeForPageSize(sz.Width, sz.Height) + r := AsyncResult{rtype: IMAGE_RESIZE, page_size: sz} + self.async_results <- r + self.lp.WakeupMainThread() + }() + } +} + func (self *Handler) rerender_diff() error { if self.diff_map != nil && self.collection != nil { err := self.render_diff() @@ -208,16 +242,17 @@ func (self *Handler) handle_async_result(r AsyncResult) error { self.restore_position = nil } self.draw_screen() - case HIGHLIGHT, IMAGE_LOAD: + case IMAGE_RESIZE: + self.images_resized_to = r.page_size + return self.rerender_diff() + case IMAGE_LOAD, HIGHLIGHT: return self.rerender_diff() } return nil } func (self *Handler) on_resize(old_size, new_size loop.ScreenSize) error { - self.screen_size.rows = int(new_size.HeightCells) - self.screen_size.num_lines = self.screen_size.rows - 1 - self.screen_size.columns = int(new_size.WidthCells) + self.update_screen_size(new_size) if self.diff_map != nil && self.collection != nil { err := self.render_diff() if err != nil { @@ -238,7 +273,7 @@ func (self *Handler) render_diff() (err error) { if self.screen_size.rows < 2 { return fmt.Errorf("Screen too short, need at least 2 rows") } - self.logical_lines, err = render(self.collection, self.diff_map, self.screen_size.columns) + self.logical_lines, err = render(self.collection, self.diff_map, self.screen_size, self.largest_line_number, self.images_resized_to) if err != nil { return err } @@ -259,8 +294,8 @@ func (self *Handler) render_diff() (err error) { func (self *Handler) draw_screen() { self.lp.StartAtomicUpdate() defer self.lp.EndAtomicUpdate() - g := (&graphics.GraphicsCommand{}).SetAction(graphics.GRT_action_delete).SetDelete(graphics.GRT_delete_visible) - g.WriteWithPayloadToLoop(self.lp, nil) + self.resize_all_images_if_needed() + image_collection.DeleteAllPlacements(self.lp) lp.MoveCursorTo(1, 1) lp.ClearToEndOfScreen() if self.logical_lines == nil || self.diff_map == nil || self.collection == nil { diff --git a/tools/tui/graphics/collection.go b/tools/tui/graphics/collection.go index 6df086681..aeb4b251c 100644 --- a/tools/tui/graphics/collection.go +++ b/tools/tui/graphics/collection.go @@ -3,10 +3,12 @@ package graphics import ( + "errors" "fmt" "sync" "sync/atomic" + "kitty/tools/tui/loop" "kitty/tools/utils/images" "golang.org/x/exp/maps" @@ -34,6 +36,24 @@ type ImageCollection struct { images map[string]*Image } +var ErrNotFound = errors.New("not found") + +func (self *ImageCollection) GetSizeIfAvailable(key string, page_size Size) (Size, error) { + if !self.mutex.TryLock() { + return Size{}, ErrNotFound + } + defer self.mutex.Unlock() + img := self.images[key] + if img == nil { + return Size{}, ErrNotFound + } + ans := img.renderings[page_size] + if ans == nil { + return Size{}, ErrNotFound + } + return Size{ans.Width, ans.Height}, img.err +} + func (self *ImageCollection) ResolutionOf(key string) Size { if !self.mutex.TryLock() { return Size{-1, -1} @@ -58,6 +78,40 @@ func (self *ImageCollection) AddPaths(paths ...string) { } } +func (self *Image) ResizeForPageSize(width, height int) { + sz := Size{width, height} + if self.renderings[sz] != nil { + return + } + final_width, final_height := images.FitImage(self.src.size.Width, self.src.size.Height, width, height) + if final_width == self.src.size.Width && final_height == self.src.data.Height { + self.renderings[sz] = self.src.data + return + } + x_frac, y_frac := float64(final_width)/float64(self.src.size.Width), float64(final_height)/float64(self.src.size.Height) + self.renderings[sz] = self.src.data.Resize(x_frac, y_frac) +} + +func (self *ImageCollection) ResizeForPageSize(width, height int) { + self.mutex.Lock() + defer self.mutex.Unlock() + + ctx := images.Context{} + keys := maps.Keys(self.images) + ctx.Parallel(0, len(keys), func(nums <-chan int) { + for i := range nums { + img := self.images[keys[i]] + img.ResizeForPageSize(width, height) + } + }) +} + +func (self *ImageCollection) DeleteAllPlacements(lp *loop.Loop) { + g := &GraphicsCommand{} + g.SetAction(GRT_action_delete).SetDelete(GRT_delete_visible) + g.WriteWithPayloadToLoop(lp, nil) +} + func (self *ImageCollection) LoadAll() { self.mutex.Lock() defer self.mutex.Unlock() diff --git a/tools/utils/images/loading.go b/tools/utils/images/loading.go index 95cae4f18..1132b8ee7 100644 --- a/tools/utils/images/loading.go +++ b/tools/utils/images/loading.go @@ -57,6 +57,28 @@ type ImageData struct { Frames []*ImageFrame } +func (self *ImageFrame) Resize(x_frac, y_frac float64) *ImageFrame { + b := self.Img.Bounds() + left, top, width, height := b.Min.X, b.Min.Y, b.Dx(), b.Dy() + ans := *self + ans.Width = int(x_frac * float64(width)) + ans.Height = int(y_frac * float64(height)) + ans.Img = imaging.Resize(self.Img, ans.Width, ans.Height, imaging.Lanczos) + ans.Left = int(x_frac * float64(left)) + ans.Top = int(y_frac * float64(top)) + return &ans + +} + +func (self *ImageData) Resize(x_frac, y_frac float64) *ImageData { + ans := *self + ans.Frames = utils.Map(func(f *ImageFrame) *ImageFrame { return f.Resize(x_frac, y_frac) }, self.Frames) + if len(ans.Frames) > 0 { + ans.Width, ans.Height = ans.Frames[0].Width, ans.Frames[0].Height + } + return &ans +} + func CalcMinimumGIFGap(gaps []int) int { // Some broken GIF images have all zero gaps, browsers with their usual // idiot ideas render these with a default 100ms gap https://bugzilla.mozilla.org/show_bug.cgi?id=125137