From f7f6df675faee910ddb6bdc82986a55cd64b775b Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Wed, 22 Mar 2023 20:58:07 +0530 Subject: [PATCH] Implement searching the diff --- tools/cmd/diff/main.go | 1 + tools/cmd/diff/render.go | 5 +- tools/cmd/diff/search.go | 138 +++++++++++++++++++++++++++++ tools/cmd/diff/ui.go | 182 +++++++++++++++++++++++++++++++++------ 4 files changed, 300 insertions(+), 26 deletions(-) create mode 100644 tools/cmd/diff/search.go diff --git a/tools/cmd/diff/main.go b/tools/cmd/diff/main.go index ff7c4c4e1..3f1f6e1cf 100644 --- a/tools/cmd/diff/main.go +++ b/tools/cmd/diff/main.go @@ -151,6 +151,7 @@ func main(_ *cli.Command, opts_ *Options, args []string) (rc int, err error) { } lp.OnResize = h.on_resize lp.OnKeyEvent = h.on_key_event + lp.OnText = h.on_text err = lp.Run() if err != nil { return 1, err diff --git a/tools/cmd/diff/render.go b/tools/cmd/diff/render.go index 774be8bcb..5e95adac3 100644 --- a/tools/cmd/diff/render.go +++ b/tools/cmd/diff/render.go @@ -67,7 +67,7 @@ func place_in(text string, sz int) string { return fill_in(fit_in(text, sz), sz) } -var title_format, text_format, margin_format, added_format, removed_format, added_margin_format, removed_margin_format, filler_format, margin_filler_format, hunk_margin_format, hunk_format, added_center, removed_center, statusline_format, added_count_format, removed_count_format func(...any) string +var title_format, text_format, margin_format, added_format, removed_format, added_margin_format, removed_margin_format, filler_format, margin_filler_format, hunk_margin_format, hunk_format, added_center, removed_center, statusline_format, added_count_format, removed_count_format, message_format func(...any) string func create_formatters() { ctx := style.Context{AllowEscapeCodes: true} @@ -89,6 +89,7 @@ func create_formatters() { removed_count_format = ctx.SprintFunc(fmt.Sprintf("fg=%s", conf.Highlight_removed_bg.AsRGBSharp())) hunk_format = ctx.SprintFunc(fmt.Sprintf("fg=%s bg=%s", conf.Margin_fg.AsRGBSharp(), conf.Hunk_bg.AsRGBSharp())) hunk_margin_format = ctx.SprintFunc(fmt.Sprintf("fg=%s bg=%s", conf.Margin_fg.AsRGBSharp(), conf.Hunk_margin_bg.AsRGBSharp())) + message_format = ctx.SprintFunc("bold") make_bracketer := func(start, end string) func(...any) string { s, e := ctx.SprintFunc(start), ctx.SprintFunc(end) end = e(" ") @@ -133,7 +134,7 @@ func title_lines(left_path, right_path string, columns, margin_size int, ans []* l1 := ll l1.screen_lines = []string{title_format(name)} l2 := ll - l2.screen_lines = []string{title_format(strings.Repeat("━", columns+1))} + l2.screen_lines = []string{title_format(strings.Repeat("━", columns))} return append(ans, &l1, &l2) } diff --git a/tools/cmd/diff/search.go b/tools/cmd/diff/search.go new file mode 100644 index 000000000..e5e4556b4 --- /dev/null +++ b/tools/cmd/diff/search.go @@ -0,0 +1,138 @@ +// License: GPLv3 Copyright: 2023, Kovid Goyal, + +package diff + +import ( + "fmt" + "regexp" + "strings" + "sync" + + "kitty/tools/tui/sgr" + "kitty/tools/utils" + "kitty/tools/utils/images" + "kitty/tools/wcswidth" + + "golang.org/x/exp/slices" +) + +var _ = fmt.Print + +type Search struct { + pat *regexp.Regexp + matches map[ScrollPos][]*sgr.Span +} + +func (self *Search) Len() int { return len(self.matches) } + +func (self *Search) find_matches_in_lines(clean_lines []string, origin int, send_result func(screen_line, offset, size int)) { + lengths := utils.Map(func(x string) int { return len(x) }, clean_lines) + offsets := make([]int, len(clean_lines)) + for i := range clean_lines { + if i > 1 { + offsets[i] = offsets[i-1] + len(clean_lines[i-1]) + } + } + matches := self.pat.FindAllStringIndex(strings.Join(clean_lines, ""), -1) + pos := 0 + + find_pos := func(start int) int { + for i := pos; i < len(clean_lines); i++ { + if start < offsets[i]+lengths[i] { + pos = i + return pos + } + + } + return -1 + } + for _, m := range matches { + start, end := m[0], m[1] + total_size := end - start + if total_size < 1 { + continue + } + start_line := find_pos(start) + if start_line > -1 { + end_line := find_pos(end) + if end_line > -1 { + for i := start_line; i <= end_line; i++ { + offset := 0 + if i == start_line { + offset = start - offsets[i] + } + size := len(clean_lines[i]) - offset + if i == end_line { + size = (end - offsets[i]) - offset + } + send_result(i, origin+offset, size) + } + } + } + } + +} + +func (self *Search) find_matches_in_line(line *LogicalLine, margin_size, cols int, send_result func(screen_line, offset, size int)) { + half_width := cols / 2 + right_offset := half_width + 1 + margin_size + left_clean_lines, right_clean_lines := make([]string, len(line.screen_lines)), make([]string, len(line.screen_lines)) + for i, line := range line.screen_lines { + line = wcswidth.StripEscapeCodes(line) + if len(line) >= half_width+1 { + left_clean_lines[i] = line[margin_size : half_width+1] + } + if len(line) > right_offset { + right_clean_lines[i] = line[right_offset:] + } + } + self.find_matches_in_lines(left_clean_lines, margin_size, send_result) + self.find_matches_in_lines(right_clean_lines, right_offset, send_result) +} + +func (self *Search) Has(pos ScrollPos) bool { + return len(self.matches[pos]) > 0 +} + +func (self *Search) search(logical_lines *LogicalLines) { + margin_size := logical_lines.margin_size + cols := logical_lines.columns + self.matches = make(map[ScrollPos][]*sgr.Span) + ctx := images.Context{} + mutex := sync.Mutex{} + s := sgr.NewSpan(0, 0) + s.SetForeground(conf.Search_fg).SetBackground(conf.Search_bg) + ctx.Parallel(0, logical_lines.Len(), func(nums <-chan int) { + for i := range nums { + line := logical_lines.At(i) + if line.line_type == EMPTY_LINE || line.line_type == IMAGE_LINE { + continue + } + self.find_matches_in_line(line, margin_size, cols, func(screen_line, offset, size int) { + mutex.Lock() + defer mutex.Unlock() + sn := *s + sn.Offset, sn.Size = offset, size + pos := ScrollPos{i, screen_line} + self.matches[pos] = append(self.matches[pos], &sn) + }) + } + }) + for _, spans := range self.matches { + slices.SortFunc(spans, func(a, b *sgr.Span) bool { return a.Offset < b.Offset }) + } +} + +func (self *Search) markup_line(line string, pos ScrollPos) string { + spans := self.matches[pos] + if spans == nil { + return line + } + return sgr.InsertFormatting(line, spans...) +} + +func do_search(pat *regexp.Regexp, logical_lines *LogicalLines) *Search { + ans := &Search{pat: pat, matches: make(map[ScrollPos][]*sgr.Span)} + ans.search(logical_lines) + return ans +} diff --git a/tools/cmd/diff/ui.go b/tools/cmd/diff/ui.go index 6e0d200db..64e259489 100644 --- a/tools/cmd/diff/ui.go +++ b/tools/cmd/diff/ui.go @@ -4,12 +4,14 @@ package diff import ( "fmt" + "regexp" "strconv" "strings" "kitty/tools/config" "kitty/tools/tui/graphics" "kitty/tools/tui/loop" + "kitty/tools/tui/readline" "kitty/tools/utils" "kitty/tools/wcswidth" ) @@ -32,6 +34,10 @@ func (self ScrollPos) Less(other ScrollPos) bool { return self.logical_line < other.logical_line || (self.logical_line == other.logical_line && self.screen_line < other.screen_line) } +func (self ScrollPos) Add(other ScrollPos) ScrollPos { + return ScrollPos{self.logical_line + other.logical_line, self.screen_line + other.screen_line} +} + type AsyncResult struct { err error rtype ResultType @@ -40,21 +46,24 @@ type AsyncResult struct { } type Handler struct { - async_results chan AsyncResult - shortcut_tracker config.ShortcutTracker - pending_keys []string - left, right string - collection *Collection - diff_map map[string]*Patch - logical_lines *LogicalLines - lp *loop.Loop - current_context_count, original_context_count int - added_count, removed_count int - screen_size struct{ rows, columns, num_lines int } - scroll_pos, max_scroll_pos ScrollPos - restore_position *ScrollPos - inputting_command bool - statusline_message string + async_results chan AsyncResult + shortcut_tracker config.ShortcutTracker + pending_keys []string + left, right string + collection *Collection + diff_map map[string]*Patch + logical_lines *LogicalLines + lp *loop.Loop + current_context_count, original_context_count int + added_count, removed_count int + screen_size struct{ rows, columns, num_lines int } + scroll_pos, max_scroll_pos ScrollPos + restore_position *ScrollPos + inputting_command bool + statusline_message string + rl *readline.Readline + current_search *Search + current_search_is_regex, current_search_is_backward bool } func (self *Handler) calculate_statistics() { @@ -70,6 +79,7 @@ var DebugPrintln func(...any) func (self *Handler) initialize() { DebugPrintln = self.lp.DebugPrintln self.pending_keys = make([]string, 0, 4) + self.rl = readline.New(self.lp, readline.RlInit{DontMarkPrompts: true, Prompt: "/"}) self.current_context_count = opts.Context if self.current_context_count < 0 { self.current_context_count = int(conf.Num_context_lines) @@ -214,8 +224,10 @@ func (self *Handler) render_diff() (err error) { self.max_scroll_pos.screen_line = 0 } self.logical_lines.IncrementScrollPosBy(&self.max_scroll_pos, -self.screen_size.num_lines+1) + if self.current_search != nil { + self.current_search.search(self.logical_lines) + } return nil - // TODO: current search see python implementation } func (self *Handler) draw_screen() { @@ -238,7 +250,10 @@ func (self *Handler) draw_screen() { if i == 0 { screen_lines = screen_lines[self.scroll_pos.screen_line:] } - for _, sl := range screen_lines { + for snum, sl := range screen_lines { + if self.current_search != nil { + sl = self.current_search.markup_line(sl, ScrollPos{i, snum}.Add(self.scroll_pos)) + } lp.QueueWriteString(sl) lp.MoveCursorVertically(1) lp.QueueWriteString("\r") @@ -257,10 +272,11 @@ func (self *Handler) draw_status_line() { } self.lp.MoveCursorTo(1, self.screen_size.rows) self.lp.ClearToEndOfLine() + self.lp.SetCursorVisible(self.inputting_command) if self.inputting_command { - // TODO: implement this + self.rl.RedrawNonAtomic() } else if self.statusline_message != "" { - // TODO: implement this + self.lp.QueueWriteString(message_format(wcswidth.TruncateToVisualLength(sanitize(self.statusline_message), self.screen_size.columns))) } else { num := self.logical_lines.NumScreenLinesTo(self.scroll_pos) den := self.logical_lines.NumScreenLinesTo(self.max_scroll_pos) @@ -269,8 +285,12 @@ func (self *Handler) draw_status_line() { frac = int((float64(num) * 100.0) / float64(den)) } sp := statusline_format(fmt.Sprintf("%d%%", frac)) - // TODO: output num of search matches - counts := added_count_format(strconv.Itoa(self.added_count)) + statusline_format(`,`) + removed_count_format(strconv.Itoa(self.removed_count)) + var counts string + if self.current_search == nil { + counts = added_count_format(strconv.Itoa(self.added_count)) + statusline_format(`,`) + removed_count_format(strconv.Itoa(self.removed_count)) + } else { + counts = statusline_format(fmt.Sprintf("%d matches", self.current_search.Len())) + } suffix := counts + " " + sp prefix := statusline_format(":") filler := strings.Repeat(" ", utils.Max(0, self.screen_size.columns-wcswidth.Stringwidth(prefix)-wcswidth.Stringwidth(suffix))) @@ -278,7 +298,77 @@ func (self *Handler) draw_status_line() { } } +func (self *Handler) on_text(text string, a, b bool) error { + if self.inputting_command { + defer self.draw_status_line() + return self.rl.OnText(text, a, b) + } + if self.statusline_message != "" { + self.statusline_message = "" + self.draw_status_line() + return nil + } + return nil +} + +func (self *Handler) do_search(query string) { + self.current_search = nil + if len(query) < 2 { + return + } + if !self.current_search_is_regex { + query = regexp.QuoteMeta(query) + } + pat, err := regexp.Compile(query) + if err != nil { + self.statusline_message = fmt.Sprintf("Bad regex: %s", err) + self.lp.Beep() + return + } + self.current_search = do_search(pat, self.logical_lines) + if self.current_search.Len() == 0 { + self.current_search = nil + self.statusline_message = fmt.Sprintf("No matches for: %#v", query) + self.lp.Beep() + } else { + if self.scroll_to_next_match(false, true) { + self.draw_screen() + } else { + self.lp.Beep() + } + } +} + func (self *Handler) on_key_event(ev *loop.KeyEvent) error { + if self.inputting_command { + defer self.draw_status_line() + if ev.MatchesPressOrRepeat("esc") { + self.inputting_command = false + ev.Handled = true + return nil + } + if ev.MatchesPressOrRepeat("enter") { + self.inputting_command = false + ev.Handled = true + self.do_search(self.rl.AllText()) + self.draw_screen() + return nil + } + return self.rl.OnKeyEvent(ev) + } + if self.statusline_message != "" { + if ev.Type != loop.RELEASE { + ev.Handled = true + self.statusline_message = "" + self.draw_status_line() + } + return nil + } + if self.current_search != nil && ev.MatchesPressOrRepeat("esc") { + self.current_search = nil + self.draw_screen() + return nil + } ac := self.shortcut_tracker.Match(ev, conf.KeyboardShortcuts) if ac != nil { return self.dispatch_action(ac.Name, ac.Args) @@ -317,8 +407,35 @@ func (self *Handler) scroll_to_next_change(backwards bool) bool { return false } -func (self *Handler) scroll_to_next_match(backwards bool) bool { - // TODO: Implement me +func (self *Handler) scroll_to_next_match(backwards, include_current_match bool) bool { + if self.current_search == nil { + return false + } + if self.current_search_is_backward { + backwards = !backwards + } + offset, delta := 1, 1 + if include_current_match { + offset = 0 + } + if backwards { + offset *= -1 + delta *= -1 + } + pos := self.scroll_pos + if self.logical_lines.IncrementScrollPosBy(&pos, offset) == 0 { + return false + } + for { + if self.current_search.Has(pos) { + self.scroll_pos = pos + self.draw_screen() + return true + } + if self.logical_lines.IncrementScrollPosBy(&pos, delta) == 0 || self.max_scroll_pos.Less(pos) { + break + } + } return false } @@ -335,6 +452,18 @@ func (self *Handler) change_context_count(val int) bool { return true } +func (self *Handler) start_search(is_regex, is_backward bool) { + if self.inputting_command { + self.lp.Beep() + return + } + self.inputting_command = true + self.current_search_is_regex = is_regex + self.current_search_is_backward = is_backward + self.rl.SetText(``) + self.draw_status_line() +} + func (self *Handler) dispatch_action(name, args string) error { switch name { case `quit`: @@ -359,7 +488,7 @@ func (self *Handler) dispatch_action(name, args string) error { case strings.Contains(args, `change`): done = self.scroll_to_next_change(strings.Contains(args, `prev`)) case strings.Contains(args, `match`): - done = self.scroll_to_next_match(strings.Contains(args, `prev`)) + done = self.scroll_to_next_match(strings.Contains(args, `prev`), false) case strings.Contains(args, `page`): amt := self.screen_size.num_lines if strings.Contains(args, `prev`) { @@ -395,6 +524,11 @@ func (self *Handler) dispatch_action(name, args string) error { if !self.change_context_count(new_ctx) { self.lp.Beep() } + case `start_search`: + if self.diff_map != nil && self.logical_lines != nil { + a, b, _ := strings.Cut(args, " ") + self.start_search(config.StringToBool(a), config.StringToBool(b)) + } } return nil }