Move the kittens Go code into the kittens folder

This commit is contained in:
Kovid Goyal
2023-03-27 13:06:02 +05:30
parent 3f9579d61d
commit ff55121094
46 changed files with 17 additions and 21 deletions

386
kittens/diff/collect.go Normal file
View File

@@ -0,0 +1,386 @@
// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
package diff
import (
"crypto/md5"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
"unicode/utf8"
"kitty/tools/utils"
)
var _ = fmt.Print
var path_name_map, remote_dirs map[string]string
var mimetypes_cache, data_cache, hash_cache *utils.LRUCache[string, string]
var size_cache *utils.LRUCache[string, int64]
var lines_cache *utils.LRUCache[string, []string]
var highlighted_lines_cache *utils.LRUCache[string, []string]
var is_text_cache *utils.LRUCache[string, bool]
func init_caches() {
path_name_map = make(map[string]string, 32)
remote_dirs = make(map[string]string, 32)
const sz = 4096
size_cache = utils.NewLRUCache[string, int64](sz)
mimetypes_cache = utils.NewLRUCache[string, string](sz)
data_cache = utils.NewLRUCache[string, string](sz)
is_text_cache = utils.NewLRUCache[string, bool](sz)
lines_cache = utils.NewLRUCache[string, []string](sz)
highlighted_lines_cache = utils.NewLRUCache[string, []string](sz)
hash_cache = utils.NewLRUCache[string, string](sz)
}
func add_remote_dir(val string) {
x := filepath.Base(val)
idx := strings.LastIndex(x, "-")
if idx > -1 {
x = x[idx+1:]
} else {
x = ""
}
remote_dirs[val] = x
}
func mimetype_for_path(path string) string {
return mimetypes_cache.MustGetOrCreate(path, func(path string) string {
mt := utils.GuessMimeTypeWithFileSystemAccess(path)
if mt == "" {
mt = "application/octet-stream"
}
if utils.KnownTextualMimes[mt] {
if _, a, found := strings.Cut(mt, "/"); found {
mt = "text/" + a
}
}
return mt
})
}
func data_for_path(path string) (string, error) {
return data_cache.GetOrCreate(path, func(path string) (string, error) {
ans, err := os.ReadFile(path)
return utils.UnsafeBytesToString(ans), err
})
}
func size_for_path(path string) (int64, error) {
return size_cache.GetOrCreate(path, func(path string) (int64, error) {
s, err := os.Stat(path)
if err != nil {
return 0, err
}
return s.Size(), nil
})
}
func is_image(path string) bool {
return strings.HasPrefix(mimetype_for_path(path), "image/")
}
func is_path_text(path string) bool {
return is_text_cache.MustGetOrCreate(path, func(path string) bool {
if is_image(path) {
return false
}
s1, err := os.Stat(path)
if err == nil {
s2, err := os.Stat("/dev/null")
if err == nil && os.SameFile(s1, s2) {
return false
}
}
d, err := data_for_path(path)
if err != nil {
return false
}
return utf8.ValidString(d)
})
}
func hash_for_path(path string) (string, error) {
return hash_cache.GetOrCreate(path, func(path string) (string, error) {
ans, err := data_for_path(path)
if err != nil {
return "", err
}
hash := md5.Sum(utils.UnsafeStringToBytes(ans))
return utils.UnsafeBytesToString(hash[:]), err
})
}
// Remove all control codes except newlines
func sanitize_control_codes(x string) string {
pat := utils.MustCompile("[\x00-\x09\x0b-\x1f\x7f\u0080-\u009f]")
return pat.ReplaceAllLiteralString(x, "░")
}
func sanitize_tabs_and_carriage_returns(x string) string {
return strings.NewReplacer("\t", conf.Replace_tab_by, "\r", "⏎").Replace(x)
}
func sanitize(x string) string {
return sanitize_control_codes(sanitize_tabs_and_carriage_returns(x))
}
func text_to_lines(text string) []string {
lines := make([]string, 0, 512)
splitlines_like_git(text, false, func(line string) { lines = append(lines, line) })
return lines
}
func lines_for_path(path string) ([]string, error) {
return lines_cache.GetOrCreate(path, func(path string) ([]string, error) {
ans, err := data_for_path(path)
if err != nil {
return nil, err
}
return text_to_lines(sanitize(ans)), nil
})
}
func highlighted_lines_for_path(path string) ([]string, error) {
plain_lines, err := lines_for_path(path)
if err != nil {
return nil, err
}
if ans, found := highlighted_lines_cache.Get(path); found && len(ans) == len(plain_lines) {
return ans, nil
}
return plain_lines, nil
}
type Collection struct {
changes, renames, type_map map[string]string
adds, removes *utils.Set[string]
all_paths []string
paths_to_highlight *utils.Set[string]
added_count, removed_count int
}
func (self *Collection) add_change(left, right string) {
self.changes[left] = right
self.all_paths = append(self.all_paths, left)
self.paths_to_highlight.Add(left)
self.paths_to_highlight.Add(right)
self.type_map[left] = `diff`
}
func (self *Collection) add_rename(left, right string) {
self.renames[left] = right
self.all_paths = append(self.all_paths, left)
self.type_map[left] = `rename`
}
func (self *Collection) add_add(right string) {
self.adds.Add(right)
self.all_paths = append(self.all_paths, right)
self.paths_to_highlight.Add(right)
self.type_map[right] = `add`
if is_path_text(right) {
num, _ := lines_for_path(right)
self.added_count += len(num)
}
}
func (self *Collection) add_removal(left string) {
self.removes.Add(left)
self.all_paths = append(self.all_paths, left)
self.paths_to_highlight.Add(left)
self.type_map[left] = `removal`
if is_path_text(left) {
num, _ := lines_for_path(left)
self.removed_count += len(num)
}
}
func (self *Collection) finalize() {
utils.StableSortWithKey(self.all_paths, func(path string) string {
return path_name_map[path]
})
}
func (self *Collection) Len() int { return len(self.all_paths) }
func (self *Collection) Items() int { return len(self.all_paths) }
func (self *Collection) Apply(f func(path, typ, changed_path string) error) error {
for _, path := range self.all_paths {
typ := self.type_map[path]
changed_path := ""
switch typ {
case "diff":
changed_path = self.changes[path]
case "rename":
changed_path = self.renames[path]
}
if err := f(path, typ, changed_path); err != nil {
return err
}
}
return nil
}
func allowed(path string, patterns ...string) bool {
name := filepath.Base(path)
for _, pat := range patterns {
if matched, err := filepath.Match(pat, name); err == nil && matched {
return false
}
}
return true
}
func remote_hostname(path string) (string, string) {
for q, val := range remote_dirs {
if strings.HasPrefix(path, q) {
return q, val
}
}
return "", ""
}
func resolve_remote_name(path, defval string) string {
remote_dir, rh := remote_hostname(path)
if remote_dir != "" && rh != "" {
r, err := filepath.Rel(remote_dir, path)
if err == nil {
return rh + ":" + r
}
}
return defval
}
func walk(base string, patterns []string, names *utils.Set[string], pmap, path_name_map map[string]string) error {
return filepath.WalkDir(base, func(path string, d fs.DirEntry, err error) error {
is_allowed := allowed(path, patterns...)
if !is_allowed {
if d.IsDir() {
return fs.SkipDir
}
return nil
}
if d.IsDir() {
return nil
}
path, err = filepath.Abs(path)
if err != nil {
return err
}
name, err := filepath.Rel(base, path)
if err != nil {
return err
}
if name != "." {
path_name_map[path] = name
names.Add(name)
pmap[name] = path
}
return nil
})
}
func (self *Collection) collect_files(left, right string) error {
left_names, right_names := utils.NewSet[string](16), utils.NewSet[string](16)
left_path_map, right_path_map := make(map[string]string, 16), make(map[string]string, 16)
err := walk(left, conf.Ignore_name, left_names, left_path_map, path_name_map)
if err != nil {
return err
}
err = walk(right, conf.Ignore_name, right_names, right_path_map, path_name_map)
common_names := left_names.Intersect(right_names)
changed_names := utils.NewSet[string](common_names.Len())
for n := range common_names.Iterable() {
ld, err := data_for_path(left_path_map[n])
var rd string
if err == nil {
rd, err = data_for_path(right_path_map[n])
}
if err != nil {
return err
}
if ld != rd {
changed_names.Add(n)
self.add_change(left_path_map[n], right_path_map[n])
}
}
removed := left_names.Subtract(common_names)
added := right_names.Subtract(common_names)
ahash, rhash := make(map[string]string, added.Len()), make(map[string]string, removed.Len())
for a := range added.Iterable() {
ahash[a], err = hash_for_path(right_path_map[a])
if err != nil {
return err
}
}
for r := range removed.Iterable() {
rhash[r], err = hash_for_path(left_path_map[r])
if err != nil {
return err
}
}
for name, rh := range rhash {
found := false
for n, ah := range ahash {
if ah == rh {
ld, _ := data_for_path(left_path_map[name])
rd, _ := data_for_path(right_path_map[n])
if ld == rd {
self.add_rename(left_path_map[name], right_path_map[n])
added.Discard(n)
found = true
break
}
}
}
if !found {
self.add_removal(left_path_map[name])
}
}
for name := range added.Iterable() {
self.add_add(right_path_map[name])
}
return nil
}
func create_collection(left, right string) (ans *Collection, err error) {
ans = &Collection{
changes: make(map[string]string),
renames: make(map[string]string),
type_map: make(map[string]string),
adds: utils.NewSet[string](32),
removes: utils.NewSet[string](32),
paths_to_highlight: utils.NewSet[string](32),
all_paths: make([]string, 0, 32),
}
left_stat, err := os.Stat(left)
if err != nil {
return nil, err
}
if left_stat.IsDir() {
err = ans.collect_files(left, right)
if err != nil {
return nil, err
}
} else {
pl, err := filepath.Abs(left)
if err != nil {
return nil, err
}
pr, err := filepath.Abs(right)
if err != nil {
return nil, err
}
path_name_map[pl] = resolve_remote_name(pl, left)
path_name_map[pr] = resolve_remote_name(pr, right)
ans.add_change(pl, pr)
}
ans.finalize()
return ans, err
}

View File

@@ -0,0 +1,53 @@
// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
package diff
import (
"fmt"
"os"
"path/filepath"
"testing"
"kitty/tools/utils"
"github.com/google/go-cmp/cmp"
)
var _ = fmt.Print
func TestDiffCollectWalk(t *testing.T) {
tdir := t.TempDir()
j := func(x ...string) string { return filepath.Join(append([]string{tdir}, x...)...) }
os.MkdirAll(j("a", "b"), 0o700)
os.WriteFile(j("a/b/c"), nil, 0o600)
os.WriteFile(j("b"), nil, 0o600)
os.WriteFile(j("d"), nil, 0o600)
os.WriteFile(j("e"), nil, 0o600)
os.WriteFile(j("#d#"), nil, 0o600)
os.WriteFile(j("e~"), nil, 0o600)
os.MkdirAll(j("f"), 0o700)
os.WriteFile(j("f/g"), nil, 0o600)
os.WriteFile(j("h space"), nil, 0o600)
expected_names := utils.NewSetWithItems("d", "e", "f/g", "h space")
expected_pmap := map[string]string{
"d": j("d"),
"e": j("e"),
"f/g": j("f/g"),
"h space": j("h space"),
}
names := utils.NewSet[string](16)
pmap := make(map[string]string, 16)
if err := walk(tdir, []string{"*~", "#*#", "b"}, names, pmap, map[string]string{}); err != nil {
t.Fatal(err)
}
if diff := cmp.Diff(
utils.Sort(expected_names.AsSlice(), func(a, b string) bool { return a < b }),
utils.Sort(names.AsSlice(), func(a, b string) bool { return a < b }),
); diff != "" {
t.Fatal(diff)
}
if diff := cmp.Diff(expected_pmap, pmap); diff != "" {
t.Fatal(diff)
}
}

264
kittens/diff/diff.go Normal file
View File

@@ -0,0 +1,264 @@
// Copied from the Go stdlib, with modifications.
//https://github.com/golang/go/raw/master/src/internal/diff/diff.go
// Copyright 2022 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package diff
import (
"bytes"
"fmt"
"sort"
"strings"
)
// A pair is a pair of values tracked for both the x and y side of a diff.
// It is typically a pair of line indexes.
type pair struct{ x, y int }
// Diff returns an anchored diff of the two texts old and new
// in the “unified diff” format. If old and new are identical,
// Diff returns a nil slice (no output).
//
// Unix diff implementations typically look for a diff with
// the smallest number of lines inserted and removed,
// which can in the worst case take time quadratic in the
// number of lines in the texts. As a result, many implementations
// either can be made to run for a long time or cut off the search
// after a predetermined amount of work.
//
// In contrast, this implementation looks for a diff with the
// smallest number of “unique” lines inserted and removed,
// where unique means a line that appears just once in both old and new.
// We call this an “anchored diff” because the unique lines anchor
// the chosen matching regions. An anchored diff is usually clearer
// than a standard diff, because the algorithm does not try to
// reuse unrelated blank lines or closing braces.
// The algorithm also guarantees to run in O(n log n) time
// instead of the standard O(n²) time.
//
// Some systems call this approach a “patience diff,” named for
// the “patience sorting” algorithm, itself named for a solitaire card game.
// We avoid that name for two reasons. First, the name has been used
// for a few different variants of the algorithm, so it is imprecise.
// Second, the name is frequently interpreted as meaning that you have
// to wait longer (to be patient) for the diff, meaning that it is a slower algorithm,
// when in fact the algorithm is faster than the standard one.
func Diff(oldName, old, newName, new string, num_of_context_lines int) []byte {
if old == new {
return nil
}
x := lines(old)
y := lines(new)
// Print diff header.
var out bytes.Buffer
fmt.Fprintf(&out, "diff %s %s\n", oldName, newName)
fmt.Fprintf(&out, "--- %s\n", oldName)
fmt.Fprintf(&out, "+++ %s\n", newName)
// Loop over matches to consider,
// expanding each match to include surrounding lines,
// and then printing diff chunks.
// To avoid setup/teardown cases outside the loop,
// tgs returns a leading {0,0} and trailing {len(x), len(y)} pair
// in the sequence of matches.
var (
done pair // printed up to x[:done.x] and y[:done.y]
chunk pair // start lines of current chunk
count pair // number of lines from each side in current chunk
ctext []string // lines for current chunk
)
for _, m := range tgs(x, y) {
if m.x < done.x {
// Already handled scanning forward from earlier match.
continue
}
// Expand matching lines as far possible,
// establishing that x[start.x:end.x] == y[start.y:end.y].
// Note that on the first (or last) iteration we may (or definitey do)
// have an empty match: start.x==end.x and start.y==end.y.
start := m
for start.x > done.x && start.y > done.y && x[start.x-1] == y[start.y-1] {
start.x--
start.y--
}
end := m
for end.x < len(x) && end.y < len(y) && x[end.x] == y[end.y] {
end.x++
end.y++
}
// Emit the mismatched lines before start into this chunk.
// (No effect on first sentinel iteration, when start = {0,0}.)
for _, s := range x[done.x:start.x] {
ctext = append(ctext, "-"+s)
count.x++
}
for _, s := range y[done.y:start.y] {
ctext = append(ctext, "+"+s)
count.y++
}
// If we're not at EOF and have too few common lines,
// the chunk includes all the common lines and continues.
C := num_of_context_lines // number of context lines
if (end.x < len(x) || end.y < len(y)) &&
(end.x-start.x < C || (len(ctext) > 0 && end.x-start.x < 2*C)) {
for _, s := range x[start.x:end.x] {
ctext = append(ctext, " "+s)
count.x++
count.y++
}
done = end
continue
}
// End chunk with common lines for context.
if len(ctext) > 0 {
n := end.x - start.x
if n > C {
n = C
}
for _, s := range x[start.x : start.x+n] {
ctext = append(ctext, " "+s)
count.x++
count.y++
}
done = pair{start.x + n, start.y + n}
// Format and emit chunk.
// Convert line numbers to 1-indexed.
// Special case: empty file shows up as 0,0 not 1,0.
if count.x > 0 {
chunk.x++
}
if count.y > 0 {
chunk.y++
}
fmt.Fprintf(&out, "@@ -%d,%d +%d,%d @@\n", chunk.x, count.x, chunk.y, count.y)
for _, s := range ctext {
out.WriteString(s)
}
count.x = 0
count.y = 0
ctext = ctext[:0]
}
// If we reached EOF, we're done.
if end.x >= len(x) && end.y >= len(y) {
break
}
// Otherwise start a new chunk.
chunk = pair{end.x - C, end.y - C}
for _, s := range x[chunk.x:end.x] {
ctext = append(ctext, " "+s)
count.x++
count.y++
}
done = end
}
return out.Bytes()
}
// lines returns the lines in the file x, including newlines.
// If the file does not end in a newline, one is supplied
// along with a warning about the missing newline.
func lines(x string) []string {
l := strings.SplitAfter(x, "\n")
if l[len(l)-1] == "" {
l = l[:len(l)-1]
} else {
// Treat last line as having a message about the missing newline attached,
// using the same text as BSD/GNU diff (including the leading backslash).
l[len(l)-1] += "\n\\ No newline at end of file\n"
}
return l
}
// tgs returns the pairs of indexes of the longest common subsequence
// of unique lines in x and y, where a unique line is one that appears
// once in x and once in y.
//
// The longest common subsequence algorithm is as described in
// Thomas G. Szymanski, “A Special Case of the Maximal Common
// Subsequence Problem,” Princeton TR #170 (January 1975),
// available at https://research.swtch.com/tgs170.pdf.
func tgs(x, y []string) []pair {
// Count the number of times each string appears in a and b.
// We only care about 0, 1, many, counted as 0, -1, -2
// for the x side and 0, -4, -8 for the y side.
// Using negative numbers now lets us distinguish positive line numbers later.
m := make(map[string]int)
for _, s := range x {
if c := m[s]; c > -2 {
m[s] = c - 1
}
}
for _, s := range y {
if c := m[s]; c > -8 {
m[s] = c - 4
}
}
// Now unique strings can be identified by m[s] = -1+-4.
//
// Gather the indexes of those strings in x and y, building:
// xi[i] = increasing indexes of unique strings in x.
// yi[i] = increasing indexes of unique strings in y.
// inv[i] = index j such that x[xi[i]] = y[yi[j]].
var xi, yi, inv []int
for i, s := range y {
if m[s] == -1+-4 {
m[s] = len(yi)
yi = append(yi, i)
}
}
for i, s := range x {
if j, ok := m[s]; ok && j >= 0 {
xi = append(xi, i)
inv = append(inv, j)
}
}
// Apply Algorithm A from Szymanski's paper.
// In those terms, A = J = inv and B = [0, n).
// We add sentinel pairs {0,0}, and {len(x),len(y)}
// to the returned sequence, to help the processing loop.
J := inv
n := len(xi)
T := make([]int, n)
L := make([]int, n)
for i := range T {
T[i] = n + 1
}
for i := 0; i < n; i++ {
k := sort.Search(n, func(k int) bool {
return T[k] >= J[i]
})
T[k] = J[i]
L[i] = k + 1
}
k := 0
for _, v := range L {
if k < v {
k = v
}
}
seq := make([]pair, 2+k)
seq[1+k] = pair{len(x), len(y)} // sentinel at end
lastj := n
for i := n - 1; i >= 0; i-- {
if L[i] == k && J[i] < lastj {
seq[k] = pair{xi[i], yi[J[i]]}
k--
}
}
seq[0] = pair{0, 0} // sentinel at start
return seq
}

207
kittens/diff/highlight.go Normal file
View File

@@ -0,0 +1,207 @@
// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
package diff
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"kitty/tools/utils"
"kitty/tools/utils/images"
"github.com/alecthomas/chroma/v2"
"github.com/alecthomas/chroma/v2/lexers"
"github.com/alecthomas/chroma/v2/styles"
)
var _ = fmt.Print
var _ = os.WriteFile
var ErrNoLexer = errors.New("No lexer available for this format")
var DefaultStyle = (&utils.Once[*chroma.Style]{Run: func() *chroma.Style {
// Default style generated by python style.py default pygments.styles.default.DefaultStyle
// with https://raw.githubusercontent.com/alecthomas/chroma/master/_tools/style.py
return styles.Register(chroma.MustNewStyle("default", chroma.StyleEntries{
chroma.TextWhitespace: "#bbbbbb",
chroma.Comment: "italic #3D7B7B",
chroma.CommentPreproc: "noitalic #9C6500",
chroma.Keyword: "bold #008000",
chroma.KeywordPseudo: "nobold",
chroma.KeywordType: "nobold #B00040",
chroma.Operator: "#666666",
chroma.OperatorWord: "bold #AA22FF",
chroma.NameBuiltin: "#008000",
chroma.NameFunction: "#0000FF",
chroma.NameClass: "bold #0000FF",
chroma.NameNamespace: "bold #0000FF",
chroma.NameException: "bold #CB3F38",
chroma.NameVariable: "#19177C",
chroma.NameConstant: "#880000",
chroma.NameLabel: "#767600",
chroma.NameEntity: "bold #717171",
chroma.NameAttribute: "#687822",
chroma.NameTag: "bold #008000",
chroma.NameDecorator: "#AA22FF",
chroma.LiteralString: "#BA2121",
chroma.LiteralStringDoc: "italic",
chroma.LiteralStringInterpol: "bold #A45A77",
chroma.LiteralStringEscape: "bold #AA5D1F",
chroma.LiteralStringRegex: "#A45A77",
chroma.LiteralStringSymbol: "#19177C",
chroma.LiteralStringOther: "#008000",
chroma.LiteralNumber: "#666666",
chroma.GenericHeading: "bold #000080",
chroma.GenericSubheading: "bold #800080",
chroma.GenericDeleted: "#A00000",
chroma.GenericInserted: "#008400",
chroma.GenericError: "#E40000",
chroma.GenericEmph: "italic",
chroma.GenericStrong: "bold",
chroma.GenericPrompt: "bold #000080",
chroma.GenericOutput: "#717171",
chroma.GenericTraceback: "#04D",
chroma.Error: "border:#FF0000",
chroma.Background: " bg:#f8f8f8",
}))
}}).Get
// Clear the background colour.
func clear_background(style *chroma.Style) *chroma.Style {
builder := style.Builder()
bg := builder.Get(chroma.Background)
bg.Background = 0
bg.NoInherit = true
builder.AddEntry(chroma.Background, bg)
style, _ = builder.Build()
return style
}
func ansi_formatter(w io.Writer, style *chroma.Style, it chroma.Iterator) error {
const SGR_PREFIX = "\033["
const SGR_SUFFIX = "m"
style = clear_background(style)
before, after := make([]byte, 0, 64), make([]byte, 0, 64)
nl := []byte{'\n'}
write_sgr := func(which []byte) {
if len(which) > 1 {
w.Write(utils.UnsafeStringToBytes(SGR_PREFIX))
w.Write(which[:len(which)-1])
w.Write(utils.UnsafeStringToBytes(SGR_SUFFIX))
}
}
write := func(text string) {
write_sgr(before)
w.Write(utils.UnsafeStringToBytes(text))
write_sgr(after)
}
for token := it(); token != chroma.EOF; token = it() {
entry := style.Get(token.Type)
before, after = before[:0], after[:0]
if !entry.IsZero() {
if entry.Bold == chroma.Yes {
before = append(before, '1', ';')
after = append(after, '2', '2', '1', ';')
}
if entry.Underline == chroma.Yes {
before = append(before, '4', ';')
after = append(after, '2', '4', ';')
}
if entry.Italic == chroma.Yes {
before = append(before, '3', ';')
after = append(after, '2', '3', ';')
}
if entry.Colour.IsSet() {
before = append(before, fmt.Sprintf("38:2:%d:%d:%d;", entry.Colour.Red(), entry.Colour.Green(), entry.Colour.Blue())...)
after = append(after, '3', '9', ';')
}
}
// independently format each line in a multiline token, needed for the diff kitten highlighting to work, also
// pagers like less reset SGR formatting at line boundaries
text := sanitize(token.Value)
for text != "" {
idx := strings.IndexByte(text, '\n')
if idx < 0 {
write(text)
break
}
write(text[:idx])
w.Write(nl)
text = text[idx+1:]
}
}
return nil
}
func highlight_file(path string) (highlighted string, err error) {
filename_for_detection := filepath.Base(path)
ext := filepath.Ext(filename_for_detection)
if ext != "" {
ext = strings.ToLower(ext[1:])
r := conf.Syntax_aliases[ext]
if r != "" {
filename_for_detection = "file." + r
}
}
text, err := data_for_path(path)
if err != nil {
return "", err
}
lexer := lexers.Match(filename_for_detection)
if lexer == nil {
if err == nil {
lexer = lexers.Analyse(text)
}
}
if lexer == nil {
return "", fmt.Errorf("Cannot highlight %#v: %w", path, ErrNoLexer)
}
lexer = chroma.Coalesce(lexer)
name := conf.Pygments_style
var style *chroma.Style
if name == "default" {
style = DefaultStyle()
} else {
style = styles.Get(name)
}
if style == nil {
if conf.Background.IsDark() && !conf.Foreground.IsDark() {
style = styles.Get("monokai")
if style == nil {
style = styles.Get("github-dark")
}
} else {
style = DefaultStyle()
}
if style == nil {
style = styles.Fallback
}
}
iterator, err := lexer.Tokenise(nil, text)
if err != nil {
return "", err
}
formatter := chroma.FormatterFunc(ansi_formatter)
w := strings.Builder{}
w.Grow(len(text) * 2)
err = formatter.Format(&w, style, iterator)
// os.WriteFile(filepath.Base(path+".highlighted"), []byte(w.String()), 0o600)
return w.String(), err
}
func highlight_all(paths []string) {
ctx := images.Context{}
ctx.Parallel(0, len(paths), func(nums <-chan int) {
for i := range nums {
path := paths[i]
raw, err := highlight_file(path)
if err == nil {
highlighted_lines_cache.Set(path, text_to_lines(raw))
}
}
})
}

174
kittens/diff/main.go Normal file
View File

@@ -0,0 +1,174 @@
// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
package diff
import (
"archive/tar"
"bytes"
"fmt"
"io/fs"
"os"
"os/exec"
"path/filepath"
"strings"
"kitty/kittens/ssh"
"kitty/tools/cli"
"kitty/tools/config"
"kitty/tools/tui/loop"
"kitty/tools/utils"
)
var _ = fmt.Print
func load_config(opts *Options) (ans *Config, err error) {
ans = NewConfig()
p := config.ConfigParser{LineHandler: ans.Parse}
err = p.LoadConfig("diff.conf", opts.Config, opts.Override)
if err != nil {
return nil, err
}
return ans, nil
}
var conf *Config
var opts *Options
var lp *loop.Loop
func isdir(path string) bool {
if s, err := os.Stat(path); err == nil {
return s.IsDir()
}
return false
}
func exists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
func get_ssh_file(hostname, rpath string) (string, error) {
tdir, err := os.MkdirTemp("", "*-"+hostname)
if err != nil {
return "", err
}
add_remote_dir(tdir)
is_abs := strings.HasPrefix(rpath, "/")
for strings.HasPrefix(rpath, "/") {
rpath = rpath[1:]
}
cmd := []string{ssh.SSHExe(), hostname, "tar", "-c", "-f", "-"}
if is_abs {
cmd = append(cmd, "-C", "/")
}
cmd = append(cmd, rpath)
c := exec.Command(cmd[0], cmd[1:]...)
c.Stdin, c.Stderr = os.Stdin, os.Stderr
stdout, err := c.Output()
if err != nil {
return "", fmt.Errorf("Failed to ssh into remote host %s to get file %s with error: %w", hostname, rpath, err)
}
tf := tar.NewReader(bytes.NewReader(stdout))
count, err := utils.ExtractAllFromTar(tf, tdir)
if err != nil {
return "", fmt.Errorf("Failed to untar data from remote host %s to get file %s with error: %w", hostname, rpath, err)
}
ans := filepath.Join(tdir, rpath)
if count == 1 {
filepath.WalkDir(tdir, func(path string, d fs.DirEntry, err error) error {
if !d.IsDir() {
ans = path
return fs.SkipAll
}
return nil
})
}
return ans, nil
}
func get_remote_file(path string) (string, error) {
if strings.HasPrefix(path, "ssh:") {
parts := strings.SplitN(path, ":", 3)
if len(parts) == 3 {
return get_ssh_file(parts[1], parts[2])
}
}
return path, nil
}
func main(_ *cli.Command, opts_ *Options, args []string) (rc int, err error) {
opts = opts_
conf, err = load_config(opts)
if err != nil {
return 1, err
}
if len(args) != 2 {
return 1, fmt.Errorf("You must specify exactly two files/directories to compare")
}
if err = set_diff_command(conf.Diff_cmd); err != nil {
return 1, err
}
init_caches()
create_formatters()
defer func() {
for tdir := range remote_dirs {
os.RemoveAll(tdir)
}
}()
left, err := get_remote_file(args[0])
if err != nil {
return 1, err
}
right, err := get_remote_file(args[1])
if err != nil {
return 1, err
}
if isdir(left) != isdir(right) {
return 1, fmt.Errorf("The items to be diffed should both be either directories or files. Comparing a directory to a file is not valid.'")
}
if !exists(left) {
return 1, fmt.Errorf("%s does not exist", left)
}
if !exists(right) {
return 1, fmt.Errorf("%s does not exist", right)
}
lp, err = loop.New()
if err != nil {
return 1, err
}
h := Handler{left: left, right: right, lp: lp}
lp.OnInitialize = func() (string, error) {
lp.SetCursorVisible(false)
lp.SetCursorShape(loop.BAR_CURSOR, true)
lp.AllowLineWrapping(false)
lp.SetWindowTitle(fmt.Sprintf("%s vs. %s", left, right))
h.initialize()
return "", nil
}
lp.OnWakeup = h.on_wakeup
lp.OnFinalize = func() string {
lp.SetCursorVisible(true)
lp.SetCursorShape(loop.BLOCK_CURSOR, true)
h.finalize()
return ""
}
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
}
ds := lp.DeathSignalName()
if ds != "" {
fmt.Println("Killed by signal: ", ds)
lp.KillIfSignalled()
return 1, nil
}
return
}
func EntryPoint(parent *cli.Command) {
create_cmd(parent, main)
}

376
kittens/diff/patch.go Normal file
View File

@@ -0,0 +1,376 @@
// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
package diff
import (
"bytes"
"errors"
"fmt"
"kitty/tools/utils"
"kitty/tools/utils/images"
"kitty/tools/utils/shlex"
"os/exec"
"path/filepath"
"strconv"
"strings"
)
var _ = fmt.Print
const GIT_DIFF = `git diff --no-color --no-ext-diff --exit-code -U_CONTEXT_ --no-index --`
const DIFF_DIFF = `diff -p -U _CONTEXT_ --`
var diff_cmd []string
var GitExe = (&utils.Once[string]{Run: func() string {
return utils.FindExe("git")
}}).Get
var DiffExe = (&utils.Once[string]{Run: func() string {
return utils.FindExe("diff")
}}).Get
func find_differ() {
if GitExe() != "git" && exec.Command(GitExe(), "--help").Run() == nil {
diff_cmd, _ = shlex.Split(GIT_DIFF)
} else if DiffExe() != "diff" && exec.Command(DiffExe(), "--help").Run() == nil {
diff_cmd, _ = shlex.Split(DIFF_DIFF)
} else {
diff_cmd = []string{}
}
}
func set_diff_command(q string) error {
switch q {
case "auto":
find_differ()
case "builtin", "":
diff_cmd = []string{}
case "diff":
diff_cmd, _ = shlex.Split(DIFF_DIFF)
case "git":
diff_cmd, _ = shlex.Split(GIT_DIFF)
default:
c, err := shlex.Split(q)
if err != nil {
return err
}
diff_cmd = c
}
return nil
}
type Center struct{ offset, left_size, right_size int }
type Chunk struct {
is_context bool
left_start, right_start int
left_count, right_count int
centers []Center
}
func (self *Chunk) add_line() {
self.right_count++
}
func (self *Chunk) remove_line() {
self.left_count++
}
func (self *Chunk) context_line() {
self.left_count++
self.right_count++
}
func changed_center(left, right string) (ans Center) {
if len(left) > 0 && len(right) > 0 {
ll, rl := len(left), len(right)
ml := utils.Min(ll, rl)
for ; ans.offset < ml && left[ans.offset] == right[ans.offset]; ans.offset++ {
}
suffix_count := 0
for ; suffix_count < ml && left[ll-1-suffix_count] == right[rl-1-suffix_count]; suffix_count++ {
}
ans.left_size = ll - suffix_count - ans.offset
ans.right_size = rl - suffix_count - ans.offset
}
return
}
func (self *Chunk) finalize(left_lines, right_lines []string) {
if !self.is_context && self.left_count == self.right_count {
for i := 0; i < self.left_count; i++ {
self.centers = append(self.centers, changed_center(left_lines[self.left_start+i], right_lines[self.right_start+i]))
}
}
}
type Hunk struct {
left_start, left_count int
right_start, right_count int
title string
added_count, removed_count int
chunks []*Chunk
current_chunk *Chunk
largest_line_number int
}
func (self *Hunk) new_chunk(is_context bool) *Chunk {
left_start, right_start := self.left_start, self.right_start
if len(self.chunks) > 0 {
c := self.chunks[len(self.chunks)-1]
left_start = c.left_start + c.left_count
right_start = c.right_start + c.right_count
}
return &Chunk{is_context: is_context, left_start: left_start, right_start: right_start}
}
func (self *Hunk) ensure_diff_chunk() {
if self.current_chunk == nil || self.current_chunk.is_context {
if self.current_chunk != nil {
self.chunks = append(self.chunks, self.current_chunk)
}
self.current_chunk = self.new_chunk(false)
}
}
func (self *Hunk) ensure_context_chunk() {
if self.current_chunk == nil || !self.current_chunk.is_context {
if self.current_chunk != nil {
self.chunks = append(self.chunks, self.current_chunk)
}
self.current_chunk = self.new_chunk(true)
}
}
func (self *Hunk) add_line() {
self.ensure_diff_chunk()
self.current_chunk.add_line()
self.added_count++
}
func (self *Hunk) remove_line() {
self.ensure_diff_chunk()
self.current_chunk.remove_line()
self.removed_count++
}
func (self *Hunk) context_line() {
self.ensure_context_chunk()
self.current_chunk.context_line()
}
func (self *Hunk) finalize(left_lines, right_lines []string) error {
if self.current_chunk != nil {
self.chunks = append(self.chunks, self.current_chunk)
}
// Sanity check
c := self.chunks[len(self.chunks)-1]
if c.left_start+c.left_count != self.left_start+self.left_count {
return fmt.Errorf("Left side line mismatch %d != %d", 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 {
return fmt.Errorf("Right side line mismatch %d != %d", c.right_start+c.right_count, self.right_start+self.right_count)
}
for _, c := range self.chunks {
c.finalize(left_lines, right_lines)
}
return nil
}
type Patch struct {
all_hunks []*Hunk
largest_line_number, added_count, removed_count int
}
func (self *Patch) Len() int { return len(self.all_hunks) }
func splitlines_like_git(raw string, strip_trailing_lines bool, process_line func(string)) {
sz := len(raw)
if strip_trailing_lines {
for sz > 0 && (raw[sz-1] == '\n' || raw[sz-1] == '\r') {
sz--
}
}
start := 0
for i := 0; i < sz; i++ {
switch raw[i] {
case '\n':
process_line(raw[start:i])
start = i + 1
case '\r':
process_line(raw[start:i])
start = i + 1
if start < sz && raw[start] == '\n' {
i++
start++
}
}
}
if start < sz {
process_line(raw[start:sz])
}
}
func parse_range(x string) (start, count int) {
s, c, found := strings.Cut(x, ",")
start, _ = strconv.Atoi(s)
if start < 0 {
start = -start
}
count = 1
if found {
count, _ = strconv.Atoi(c)
}
return
}
func parse_hunk_header(line string) *Hunk {
parts := strings.SplitN(line, "@@", 3)
linespec := strings.TrimSpace(parts[1])
title := ""
if len(parts) == 3 {
title = strings.TrimSpace(parts[2])
}
left, right, _ := strings.Cut(linespec, " ")
ls, lc := parse_range(left)
rs, rc := parse_range(right)
return &Hunk{
title: title, left_start: ls - 1, left_count: lc, right_start: rs - 1, right_count: rc,
largest_line_number: utils.Max(ls-1+lc, rs-1+rc),
}
}
func parse_patch(raw string, left_lines, right_lines []string) (ans *Patch, err error) {
ans = &Patch{all_hunks: make([]*Hunk, 0, 32)}
var current_hunk *Hunk
splitlines_like_git(raw, true, func(line string) {
if strings.HasPrefix(line, "@@ ") {
current_hunk = parse_hunk_header(line)
ans.all_hunks = append(ans.all_hunks, current_hunk)
} else if current_hunk != nil {
var ch byte
if len(line) > 0 {
ch = line[0]
}
switch ch {
case '+':
current_hunk.add_line()
case '-':
current_hunk.remove_line()
case '\\':
default:
current_hunk.context_line()
}
}
})
for _, h := range ans.all_hunks {
err = h.finalize(left_lines, right_lines)
if err != nil {
return
}
ans.added_count += h.added_count
ans.removed_count += h.removed_count
}
if len(ans.all_hunks) > 0 {
ans.largest_line_number = ans.all_hunks[len(ans.all_hunks)-1].largest_line_number
}
return
}
func run_diff(file1, file2 string, num_of_context_lines int) (ok, is_different bool, patch string, err error) {
// 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, err := filepath.EvalSymlinks(file1)
if err != nil {
return
}
path2, err := filepath.EvalSymlinks(file2)
if err != nil {
return
}
if len(diff_cmd) == 0 {
data1, err := data_for_path(path1)
if err != nil {
return false, false, "", err
}
data2, err := data_for_path(path2)
if err != nil {
return false, false, "", err
}
patchb := Diff(path1, data1, path2, data2, num_of_context_lines)
if patchb == nil {
return true, false, "", nil
}
return true, len(patchb) > 0, utils.UnsafeBytesToString(patchb), nil
} else {
context := strconv.Itoa(num_of_context_lines)
cmd := utils.Map(func(x string) string {
return strings.ReplaceAll(x, "_CONTEXT_", context)
}, diff_cmd)
cmd = append(cmd, path1, path2)
c := exec.Command(cmd[0], cmd[1:]...)
stdout, stderr := bytes.Buffer{}, bytes.Buffer{}
c.Stdout, c.Stderr = &stdout, &stderr
err = c.Run()
if err != nil {
var e *exec.ExitError
if errors.As(err, &e) && e.ExitCode() == 1 {
return true, true, stdout.String(), nil
}
return false, false, stderr.String(), err
}
return true, false, stdout.String(), nil
}
}
func do_diff(file1, file2 string, context_count int) (ans *Patch, err error) {
ok, _, raw, err := run_diff(file1, file2, context_count)
if !ok {
return nil, fmt.Errorf("Failed to diff %s vs. %s with errors:\n%s", file1, file2, raw)
}
if err != nil {
return
}
left_lines, err := lines_for_path(file1)
if err != nil {
return
}
right_lines, err := lines_for_path(file2)
if err != nil {
return
}
ans, err = parse_patch(raw, left_lines, right_lines)
return
}
type diff_job struct{ file1, file2 string }
func diff(jobs []diff_job, context_count int) (ans map[string]*Patch, err error) {
ans = make(map[string]*Patch)
ctx := images.Context{}
type result struct {
file1, file2 string
err error
patch *Patch
}
results := make(chan result, len(jobs))
ctx.Parallel(0, len(jobs), func(nums <-chan int) {
for i := range nums {
job := jobs[i]
r := result{file1: job.file1, file2: job.file2}
r.patch, r.err = do_diff(job.file1, job.file2, context_count)
results <- r
}
})
close(results)
for r := range results {
if r.err != nil {
return nil, r.err
}
ans[r.file1] = r.patch
}
return ans, nil
}

652
kittens/diff/render.go Normal file
View File

@@ -0,0 +1,652 @@
// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
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"
)
var _ = fmt.Print
type LineType int
const (
TITLE_LINE LineType = iota
FULL_TITLE_LINE
CHANGE_LINE
HUNK_TITLE_LINE
IMAGE_LINE
EMPTY_LINE
)
type Reference struct {
path string
linenum int
}
type LogicalLine struct {
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) {
if len(self.screen_lines) > 0 {
npos := utils.Max(0, utils.Min(pos.screen_line+amt, len(self.screen_lines)-1))
delta = npos - pos.screen_line
pos.screen_line = npos
}
return
}
func join_half_lines(left, right string) string {
return left + "\x1b[m" + right + "\x1b[m"
}
func fit_in(text string, count int) string {
truncated := wcswidth.TruncateToVisualLength(text, count)
if len(truncated) >= len(text) {
return text
}
if count > 1 {
truncated = wcswidth.TruncateToVisualLength(text, count-1)
}
return truncated + ``
}
func fill_in(text string, sz int) string {
w := wcswidth.Stringwidth(text)
if w < sz {
text += strings.Repeat(` `, (sz - w))
}
return text
}
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, statusline_format, added_count_format, removed_count_format, message_format func(...any) string
func create_formatters() {
ctx := style.Context{AllowEscapeCodes: true}
text_format = ctx.SprintFunc(fmt.Sprintf("bg=%s", conf.Background.AsRGBSharp()))
filler_format = ctx.SprintFunc(fmt.Sprintf("bg=%s", conf.Filler_bg.AsRGBSharp()))
if conf.Margin_filler_bg.IsSet {
margin_filler_format = ctx.SprintFunc(fmt.Sprintf("bg=%s", conf.Margin_filler_bg.Color.AsRGBSharp()))
} else {
margin_filler_format = ctx.SprintFunc(fmt.Sprintf("bg=%s", conf.Filler_bg.AsRGBSharp()))
}
added_format = ctx.SprintFunc(fmt.Sprintf("bg=%s", conf.Added_bg.AsRGBSharp()))
added_margin_format = ctx.SprintFunc(fmt.Sprintf("fg=%s bg=%s", conf.Margin_fg.AsRGBSharp(), conf.Added_margin_bg.AsRGBSharp()))
removed_format = ctx.SprintFunc(fmt.Sprintf("bg=%s", conf.Removed_bg.AsRGBSharp()))
removed_margin_format = ctx.SprintFunc(fmt.Sprintf("fg=%s bg=%s", conf.Margin_fg.AsRGBSharp(), conf.Removed_margin_bg.AsRGBSharp()))
title_format = ctx.SprintFunc(fmt.Sprintf("fg=%s bg=%s bold", conf.Title_fg.AsRGBSharp(), conf.Title_bg.AsRGBSharp()))
margin_format = ctx.SprintFunc(fmt.Sprintf("fg=%s bg=%s", conf.Margin_fg.AsRGBSharp(), conf.Margin_bg.AsRGBSharp()))
statusline_format = ctx.SprintFunc(fmt.Sprintf("fg=%s", conf.Margin_fg.AsRGBSharp()))
added_count_format = ctx.SprintFunc(fmt.Sprintf("fg=%s", conf.Highlight_added_bg.AsRGBSharp()))
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")
}
func center_span(ltype string, offset, size int) *sgr.Span {
ans := sgr.NewSpan(offset, size)
switch ltype {
case "add":
ans.SetBackground(conf.Highlight_added_bg).SetClosingBackground(conf.Added_bg)
case "remove":
ans.SetBackground(conf.Highlight_removed_bg).SetClosingBackground(conf.Removed_bg)
}
return ans
}
func title_lines(left_path, right_path string, columns, margin_size int, ans []*LogicalLine) []*LogicalLine {
left_name, right_name := path_name_map[left_path], path_name_map[right_path]
name := ""
m := strings.Repeat(` `, margin_size)
ll := LogicalLine{line_type: TITLE_LINE, src: Reference{path: left_path, linenum: 0}}
if right_name != "" && right_name != left_name {
n1 := fit_in(m+sanitize(left_name), 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), columns)
ll.line_type = FULL_TITLE_LINE
}
l1 := ll
l1.screen_lines = []string{title_format(name)}
l2 := ll
l2.line_type = EMPTY_LINE
l2.screen_lines = []string{title_format(strings.Repeat("━", columns))}
return append(ans, &l1, &l2)
}
type LogicalLines struct {
lines []*LogicalLine
margin_size, columns int
}
func (self *LogicalLines) At(i int) *LogicalLine { return self.lines[i] }
func (self *LogicalLines) ScreenLineAt(pos ScrollPos) string {
if pos.logical_line < len(self.lines) && pos.logical_line >= 0 {
line := self.lines[pos.logical_line]
if pos.screen_line < len(line.screen_lines) && pos.screen_line >= 0 {
return self.lines[pos.logical_line].screen_lines[pos.screen_line]
}
}
return ""
}
func (self *LogicalLines) Len() int { return len(self.lines) }
func (self *LogicalLines) NumScreenLinesTo(a ScrollPos) (ans int) {
return self.Minus(a, ScrollPos{})
}
// a - b in terms of number of screen lines between the positions
func (self *LogicalLines) Minus(a, b ScrollPos) (delta int) {
if a.logical_line == b.logical_line {
return a.screen_line - b.screen_line
}
amt := 1
if a.Less(b) {
amt = -1
} else {
a, b = b, a
}
for i := a.logical_line; i < utils.Min(len(self.lines), b.logical_line+1); i++ {
line := self.lines[i]
switch i {
case a.logical_line:
delta += utils.Max(0, len(line.screen_lines)-a.screen_line)
case b.logical_line:
delta += b.screen_line
default:
delta += len(line.screen_lines)
}
}
return delta * amt
}
func (self *LogicalLines) IncrementScrollPosBy(pos *ScrollPos, amt int) (delta int) {
if pos.logical_line < 0 || pos.logical_line >= len(self.lines) || amt == 0 {
return
}
one := 1
if amt < 0 {
one = -1
}
for amt != 0 {
line := self.lines[pos.logical_line]
d := line.IncrementScrollPosBy(pos, amt)
if d == 0 {
nlp := pos.logical_line + one
if nlp < 0 || nlp >= len(self.lines) {
break
}
pos.logical_line = nlp
if one > 0 {
pos.screen_line = 0
} else {
pos.screen_line = len(self.lines[nlp].screen_lines) - 1
}
delta += one
amt -= one
} else {
amt -= d
delta += d
}
}
return
}
func human_readable(size int64) string {
divisor, suffix := 1, "B"
for i, candidate := range []string{"B", "KB", "MB", "GB", "TB", "PB", "EB"} {
if size < (1 << ((i + 1) * 10)) {
divisor, suffix = (1 << (i * 10)), candidate
break
}
}
fs := float64(size) / float64(divisor)
s := strconv.FormatFloat(fs, 'f', 2, 64)
if idx := strings.Index(s, "."); idx > -1 {
s = s[:idx+2]
}
if strings.HasSuffix(s, ".0") || strings.HasSuffix(s, ".00") {
idx := strings.IndexByte(s, '.')
s = s[:idx]
}
return s + " " + suffix
}
func render_diff_line(number, text, ltype string, margin_size int, available_cols int) string {
m, c := margin_format, text_format
switch ltype {
case `filler`:
m = margin_filler_format
c = filler_format
case `remove`:
m = removed_margin_format
c = removed_format
case `add`:
m = added_margin_format
c = added_format
}
margin := m(place_in(number, margin_size))
content := c(fill_in(text, available_cols))
return margin + content
}
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, margin_formatter formatter) (string, error) {
sz, err := size_for_path(path)
if err != nil {
return "", err
}
text := fmt.Sprintf("Size: %s", human_readable(sz))
res := image_collection.ResolutionOf(path)
if res.Width > -1 {
text = fmt.Sprintf("Dimensions: %dx%d %s", res.Width, res.Height, text)
}
text = place_in(text, available_cols)
return margin_formatter(strings.Repeat(` `, margin_size)) + formatter(text), err
})
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.Max(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
}
type formatter = func(...any) string
func first_binary_line(left_path, right_path string, columns, margin_size int, renderer func(path string, formatter, margin_formatter formatter) (string, error)) (*LogicalLine, error) {
available_cols := columns/2 - margin_size
line := ""
if left_path == "" {
filler := render_diff_line(``, ``, `filler`, margin_size, available_cols)
r, err := renderer(right_path, added_format, added_margin_format)
if err != nil {
return nil, err
}
line = filler + r
} else if right_path == "" {
filler := render_diff_line(``, ``, `filler`, margin_size, available_cols)
l, err := renderer(left_path, removed_format, removed_margin_format)
if err != nil {
return nil, err
}
line = l + filler
} else {
l, err := renderer(left_path, removed_format, removed_margin_format)
if err != nil {
return nil, err
}
r, err := renderer(right_path, added_format, added_margin_format)
if err != nil {
return nil, err
}
line = l + r
}
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
}
return &ll, nil
}
func binary_lines(left_path, right_path string, columns, margin_size int, ans []*LogicalLine) (ans2 []*LogicalLine, err error) {
available_cols := columns/2 - margin_size
ll, err := first_binary_line(left_path, right_path, columns, margin_size, func(path string, formatter, margin_formatter formatter) (string, error) {
sz, err := size_for_path(path)
if err != nil {
return "", err
}
text := fmt.Sprintf("Binary file: %s", human_readable(sz))
text = place_in(text, available_cols)
return margin_formatter(strings.Repeat(` `, margin_size)) + formatter(text), err
})
if err != nil {
return nil, err
}
return append(ans, ll), nil
}
type DiffData struct {
left_path, right_path string
available_cols, margin_size int
left_lines, right_lines []string
filler_line, left_filler_line, right_filler_line string
}
func hunk_title(hunk_num int, hunk *Hunk, margin_size, available_cols int) string {
m := hunk_margin_format(strings.Repeat(" ", margin_size))
t := fmt.Sprintf("@@ -%d,%d +%d,%d @@ %s", 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))
}
func lines_for_context_chunk(data *DiffData, hunk_num int, chunk *Chunk, chunk_num int, ans []*LogicalLine) []*LogicalLine {
for i := 0; i < chunk.left_count; i++ {
left_line_number := chunk.left_start + i
right_line_number := chunk.right_start + i
ll := LogicalLine{line_type: CHANGE_LINE, src: Reference{path: data.left_path, linenum: left_line_number}}
left_line_number_s := strconv.Itoa(left_line_number + 1)
right_line_number_s := strconv.Itoa(right_line_number + 1)
for _, text := range splitlines(data.left_lines[left_line_number], data.available_cols) {
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 {
line += line
} else {
line += render_diff_line(right_line_number_s, text, `context`, data.margin_size, data.available_cols)
}
ll.screen_lines = append(ll.screen_lines, line)
left_line_number_s, right_line_number_s = "", ""
}
ans = append(ans, &ll)
}
return ans
}
func splitlines(text string, width int) []string {
return style.WrapTextAsLines(text, "", width)
}
func render_half_line(line_number int, line, ltype string, margin_size, available_cols int, center Center, ans []string) []string {
size := center.left_size
if ltype != "remove" {
size = center.right_size
}
if size > 0 {
span := center_span(ltype, center.offset, size)
line = sgr.InsertFormatting(line, span)
}
lnum := strconv.Itoa(line_number + 1)
for _, sc := range splitlines(line, available_cols) {
ans = append(ans, render_diff_line(lnum, sc, ltype, margin_size, available_cols))
lnum = ""
}
return ans
}
func lines_for_diff_chunk(data *DiffData, hunk_num int, chunk *Chunk, chunk_num int, ans []*LogicalLine) []*LogicalLine {
common := utils.Min(chunk.left_count, chunk.right_count)
ll, rl := make([]string, 0, 32), make([]string, 0, 32)
for i := 0; i < utils.Max(chunk.left_count, chunk.right_count); i++ {
ll, rl = ll[:0], rl[:0]
ref_ln, ref_path := 0, ""
var center Center
if i < len(chunk.centers) {
center = chunk.centers[i]
}
if i < chunk.left_count {
ref_path = data.left_path
ref_ln = chunk.left_start + i
ll = render_half_line(ref_ln, data.left_lines[ref_ln], "remove", data.margin_size, data.available_cols, center, ll)
}
if i < chunk.right_count {
ref_path = data.right_path
ref_ln = chunk.right_start + i
rl = render_half_line(ref_ln, data.right_lines[ref_ln], "add", data.margin_size, data.available_cols, center, rl)
}
if i < common {
extra := len(ll) - len(rl)
if extra < 0 {
ll = append(ll, utils.Repeat(data.left_filler_line, -extra)...)
} else if extra > 0 {
rl = append(rl, utils.Repeat(data.right_filler_line, extra)...)
}
} else {
if len(ll) > 0 {
rl = append(rl, utils.Repeat(data.filler_line, len(ll))...)
} else if len(rl) > 0 {
ll = append(ll, utils.Repeat(data.filler_line, len(rl))...)
}
}
logline := LogicalLine{line_type: CHANGE_LINE, src: Reference{path: ref_path, linenum: ref_ln}, is_change_start: i == 0}
for l := 0; l < len(ll); l++ {
logline.screen_lines = append(logline.screen_lines, join_half_lines(ll[l], rl[l]))
}
ans = append(ans, &logline)
}
return ans
}
func lines_for_diff(left_path string, right_path string, patch *Patch, columns, margin_size int, ans []*LogicalLine) (result []*LogicalLine, err error) {
ht := LogicalLine{line_type: HUNK_TITLE_LINE, src: Reference{path: left_path}}
if patch.Len() == 0 {
ht.screen_lines = []string{"The files are identical"}
ht.line_type = EMPTY_LINE
ans = append(ans, &ht)
return ans, nil
}
available_cols := columns/2 - margin_size
data := DiffData{left_path: left_path, right_path: right_path, available_cols: available_cols, margin_size: margin_size}
if left_path != "" {
data.left_lines, err = highlighted_lines_for_path(left_path)
if err != nil {
return
}
}
if right_path != "" {
data.right_lines, err = highlighted_lines_for_path(right_path)
if err != nil {
return
}
}
data.filler_line = render_diff_line("", "", "filler", margin_size, available_cols)
data.left_filler_line = render_diff_line("", "", "remove", margin_size, available_cols)
data.right_filler_line = render_diff_line("", "", "add", margin_size, available_cols)
for hunk_num, hunk := range patch.all_hunks {
htl := ht
htl.src.linenum = hunk.left_start
htl.screen_lines = []string{hunk_title(hunk_num, hunk, margin_size, columns-margin_size)}
ans = append(ans, &htl)
for cnum, chunk := range hunk.chunks {
if chunk.is_context {
ans = lines_for_context_chunk(&data, hunk_num, chunk, cnum, ans)
} else {
ans = lines_for_diff_chunk(&data, hunk_num, chunk, cnum, ans)
}
}
}
return ans, nil
}
func all_lines(path string, columns, margin_size int, is_add bool, ans []*LogicalLine) ([]*LogicalLine, error) {
available_cols := columns/2 - margin_size
ltype := `add`
if !is_add {
ltype = `remove`
}
lines, err := highlighted_lines_for_path(path)
if err != nil {
return nil, err
}
filler := render_diff_line(``, ``, `filler`, margin_size, available_cols)
msg_written := false
ll := LogicalLine{src: Reference{path: path}, line_type: CHANGE_LINE}
for line_number, line := range lines {
hlines := make([]string, 0, 8)
hlines = render_half_line(line_number, line, ltype, margin_size, available_cols, Center{}, hlines)
l := ll
l.src.linenum = line_number
l.is_change_start = line_number == 0
for _, hl := range hlines {
empty := filler
if !msg_written {
msg_written = true
msg := `This file was added`
if !is_add {
msg = `This file was removed`
}
empty = render_diff_line(``, msg, `filler`, margin_size, available_cols)
}
var text string
if is_add {
text = join_half_lines(empty, hl)
} else {
text = join_half_lines(hl, empty)
}
l.screen_lines = append(l.screen_lines, text)
}
ans = append(ans, &l)
}
return ans, nil
}
func rename_lines(path, other_path string, columns, margin_size int, ans []*LogicalLine) ([]*LogicalLine, error) {
m := strings.Repeat(" ", margin_size)
ll := LogicalLine{src: Reference{path: path, linenum: 0}, line_type: CHANGE_LINE, is_change_start: true}
for _, line := range splitlines(fmt.Sprintf(`The file %s was renamed to %s`, sanitize(path_name_map[path]), sanitize(path_name_map[other_path])), columns-margin_size) {
ll.screen_lines = append(ll.screen_lines, m+line)
}
return append(ans, &ll), 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() {
el := empty_line
ans = append(ans, &el)
}()
is_binary := !is_path_text(path)
if !is_binary && item_type == `diff` && !is_path_text(changed_path) {
is_binary = true
}
is_img := is_binary && is_image(path) || (item_type == `diff` && is_image(changed_path))
_ = is_img
switch item_type {
case "diff":
if is_binary {
if is_img {
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)
}
} else {
ans, err = lines_for_diff(path, changed_path, diff_map[path], columns, margin_size, ans)
}
if err != nil {
return err
}
case "add":
if is_binary {
if is_img {
ans, err = image_lines("", path, screen_size, margin_size, image_size, ans)
} else {
ans, err = binary_lines("", path, columns, margin_size, ans)
}
} else {
ans, err = all_lines(path, columns, margin_size, true, ans)
}
if err != nil {
return err
}
case "removal":
if is_binary {
if is_img {
ans, err = image_lines(path, "", screen_size, margin_size, image_size, ans)
} else {
ans, err = binary_lines(path, "", columns, margin_size, ans)
}
} else {
ans, err = all_lines(path, columns, margin_size, false, ans)
}
if err != nil {
return err
}
case "rename":
ans, err = rename_lines(path, changed_path, columns, margin_size, ans)
if err != nil {
return err
}
default:
return fmt.Errorf("Unknown change type: %#v", item_type)
}
return nil
})
return &LogicalLines{lines: ans[:len(ans)-1], margin_size: margin_size, columns: columns}, err
}
func (self *LogicalLines) num_of_screen_lines() (ans int) {
for _, l := range self.lines {
ans += len(l.screen_lines)
}
return
}

145
kittens/diff/search.go Normal file
View File

@@ -0,0 +1,145 @@
// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
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 > 0 {
offsets[i] = offsets[i-1] + lengths[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 + margin_size
left_clean_lines, right_clean_lines := make([]string, len(line.screen_lines)), make([]string, len(line.screen_lines))
lt := line.line_type
for i, line := range line.screen_lines {
line = wcswidth.StripEscapeCodes(line)
if lt == HUNK_TITLE_LINE || lt == FULL_TITLE_LINE {
if len(line) > margin_size {
left_clean_lines[i] = line[margin_size:]
}
} else {
if len(line) >= half_width+1 {
left_clean_lines[i] = line[margin_size:half_width]
}
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
}

629
kittens/diff/ui.go Normal file
View File

@@ -0,0 +1,629 @@
// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
package diff
import (
"fmt"
"regexp"
"strconv"
"strings"
"kitty/tools/config"
"kitty/tools/tty"
"kitty/tools/tui/graphics"
"kitty/tools/tui/loop"
"kitty/tools/tui/readline"
"kitty/tools/utils"
"kitty/tools/wcswidth"
)
var _ = fmt.Print
type ResultType int
const (
COLLECTION ResultType = iota
DIFF
HIGHLIGHT
IMAGE_LOAD
IMAGE_RESIZE
)
type ScrollPos struct {
logical_line, screen_line int
}
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
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
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 screen_size
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
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) on_escape_code(etype loop.EscapeCodeType, payload []byte) error {
switch etype {
case loop.APC:
gc := graphics.GraphicsCommandFromAPC(payload)
if gc != nil {
if !image_collection.HandleGraphicsCommand(gc) {
self.draw_screen()
}
}
}
return nil
}
func (self *Handler) finalize() {
image_collection.Finalize(self.lp)
}
func (self *Handler) initialize() {
self.rl = readline.New(self.lp, readline.RlInit{DontMarkPrompts: true, Prompt: "/"})
self.lp.OnEscapeCode = self.on_escape_code
image_collection = graphics.NewImageCollection()
image_collection.Initialize(self.lp)
self.current_context_count = opts.Context
if self.current_context_count < 0 {
self.current_context_count = int(conf.Num_context_lines)
}
sz, _ := self.lp.ScreenSize()
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)
self.lp.SetDefaultColor(loop.BACKGROUND, conf.Background)
self.lp.SetDefaultColor(loop.SELECTION_BG, conf.Select_bg)
if conf.Select_fg.IsSet {
self.lp.SetDefaultColor(loop.SELECTION_FG, conf.Select_fg.Color)
}
self.async_results = make(chan AsyncResult, 32)
go func() {
r := AsyncResult{}
r.collection, r.err = create_collection(self.left, self.right)
self.async_results <- r
self.lp.WakeupMainThread()
}()
self.draw_screen()
}
func (self *Handler) generate_diff() {
self.diff_map = nil
jobs := make([]diff_job, 0, 32)
self.collection.Apply(func(path, typ, changed_path string) error {
if typ == "diff" {
if is_path_text(path) && is_path_text(changed_path) {
jobs = append(jobs, diff_job{path, changed_path})
}
}
return nil
})
go func() {
r := AsyncResult{rtype: DIFF}
r.diff_map, r.err = diff(jobs, self.current_context_count)
self.async_results <- r
self.lp.WakeupMainThread()
}()
}
func (self *Handler) on_wakeup() error {
var r AsyncResult
for {
select {
case r = <-self.async_results:
if r.err != nil {
return r.err
}
r.err = self.handle_async_result(r)
if r.err != nil {
return r.err
}
default:
return nil
}
}
}
func (self *Handler) highlight_all() {
text_files := utils.Filter(self.collection.paths_to_highlight.AsSlice(), is_path_text)
go func() {
r := AsyncResult{rtype: HIGHLIGHT}
highlight_all(text_files)
self.async_results <- r
self.lp.WakeupMainThread()
}()
}
func (self *Handler) load_all_images() {
self.collection.Apply(func(path, item_type, changed_path string) error {
if path != "" && is_image(path) {
image_collection.AddPaths(path)
}
if changed_path != "" && is_image(changed_path) {
image_collection.AddPaths(changed_path)
}
return nil
})
go func() {
r := AsyncResult{rtype: IMAGE_LOAD}
image_collection.LoadAll()
self.async_results <- r
self.lp.WakeupMainThread()
}()
}
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()
if err != nil {
return err
}
self.draw_screen()
}
return nil
}
func (self *Handler) handle_async_result(r AsyncResult) error {
switch r.rtype {
case COLLECTION:
self.collection = r.collection
self.generate_diff()
self.highlight_all()
self.load_all_images()
case DIFF:
self.diff_map = r.diff_map
self.calculate_statistics()
err := self.render_diff()
if err != nil {
return err
}
self.scroll_pos = ScrollPos{}
if self.restore_position != nil {
self.scroll_pos = *self.restore_position
if self.max_scroll_pos.Less(self.scroll_pos) {
self.scroll_pos = self.max_scroll_pos
}
self.restore_position = nil
}
self.draw_screen()
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.update_screen_size(new_size)
if self.diff_map != nil && self.collection != nil {
err := self.render_diff()
if err != nil {
return err
}
if self.max_scroll_pos.Less(self.scroll_pos) {
self.scroll_pos = self.max_scroll_pos
}
}
self.draw_screen()
return nil
}
func (self *Handler) render_diff() (err error) {
if self.screen_size.columns < 8 {
return fmt.Errorf("Screen too narrow, need at least 8 columns")
}
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, self.largest_line_number, self.images_resized_to)
if err != nil {
return err
}
last := self.logical_lines.Len() - 1
self.max_scroll_pos.logical_line = last
if last > -1 {
self.max_scroll_pos.screen_line = len(self.logical_lines.At(last).screen_lines) - 1
} else {
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
}
func (self *Handler) draw_image(key string, num_rows, starting_row int) {
image_collection.PlaceImageSubRect(self.lp, key, self.images_resized_to, 0, self.screen_size.cell_height*starting_row, -1, -1)
}
func (self *Handler) draw_image_pair(ll *LogicalLine, starting_row int) {
if ll.left_image.key != "" {
self.lp.MoveCursorHorizontally(self.logical_lines.margin_size)
self.draw_image(ll.left_image.key, ll.left_image.count, starting_row)
self.lp.QueueWriteString("\r")
}
if ll.right_image.key != "" {
self.lp.MoveCursorHorizontally(self.logical_lines.margin_size + self.logical_lines.columns/2)
self.draw_image(ll.right_image.key, ll.right_image.count, starting_row)
self.lp.QueueWriteString("\r")
}
}
func (self *Handler) draw_screen() {
self.lp.StartAtomicUpdate()
defer self.lp.EndAtomicUpdate()
self.resize_all_images_if_needed()
image_collection.DeleteAllVisiblePlacements(self.lp)
lp.MoveCursorTo(1, 1)
lp.ClearToEndOfScreen()
if self.logical_lines == nil || self.diff_map == nil || self.collection == nil {
lp.Println(`Calculating diff, please wait...`)
return
}
pos := self.scroll_pos
seen_images := utils.NewSet[int]()
for num_written := 0; num_written < self.screen_size.num_lines; num_written++ {
ll := self.logical_lines.At(pos.logical_line)
is_image := ll != nil && ll.line_type == IMAGE_LINE
sl := self.logical_lines.ScreenLineAt(pos)
if is_image && !seen_images.Has(pos.logical_line) && pos.screen_line >= ll.image_lines_offset {
seen_images.Add(pos.logical_line)
self.draw_image_pair(ll, pos.screen_line-ll.image_lines_offset)
}
if self.current_search != nil {
sl = self.current_search.markup_line(sl, pos)
}
lp.QueueWriteString(sl)
lp.MoveCursorVertically(1)
lp.QueueWriteString("\r")
if self.logical_lines.IncrementScrollPosBy(&pos, 1) == 0 {
break
}
}
self.draw_status_line()
}
func (self *Handler) draw_status_line() {
if self.logical_lines == nil || self.diff_map == nil {
return
}
self.lp.MoveCursorTo(1, self.screen_size.rows)
self.lp.ClearToEndOfLine()
self.lp.SetCursorVisible(self.inputting_command)
if self.inputting_command {
self.rl.RedrawNonAtomic()
} else if self.statusline_message != "" {
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)
var frac int
if den > 0 {
frac = int((float64(num) * 100.0) / float64(den))
}
sp := statusline_format(fmt.Sprintf("%d%%", frac))
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)))
self.lp.QueueWriteString(prefix + filler + suffix)
}
}
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(`(?i)` + 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)
}
return nil
}
func (self *Handler) scroll_lines(amt int) (delta int) {
before := self.scroll_pos
delta = self.logical_lines.IncrementScrollPosBy(&self.scroll_pos, amt)
if delta > 0 && self.max_scroll_pos.Less(self.scroll_pos) {
self.scroll_pos = self.max_scroll_pos
delta = self.logical_lines.Minus(self.scroll_pos, before)
}
return
}
func (self *Handler) scroll_to_next_change(backwards bool) bool {
if backwards {
for i := self.scroll_pos.logical_line - 1; i >= 0; i-- {
line := self.logical_lines.At(i)
if line.is_change_start {
self.scroll_pos = ScrollPos{i, 0}
return true
}
}
} else {
for i := self.scroll_pos.logical_line + 1; i < self.logical_lines.Len(); i++ {
line := self.logical_lines.At(i)
if line.is_change_start {
self.scroll_pos = ScrollPos{i, 0}
return true
}
}
}
return false
}
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 offset != 0 && 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
}
func (self *Handler) change_context_count(val int) bool {
val = utils.Max(0, val)
if val == self.current_context_count {
return false
}
self.current_context_count = val
p := self.scroll_pos
self.restore_position = &p
self.generate_diff()
self.draw_screen()
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`:
self.lp.Quit(0)
case `scroll_by`:
if args == "" {
args = "1"
}
amt, err := strconv.Atoi(args)
if err == nil {
if self.scroll_lines(amt) == 0 {
self.lp.Beep()
} else {
self.draw_screen()
}
} else {
self.lp.Beep()
}
case `scroll_to`:
done := false
switch {
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`), false)
case strings.Contains(args, `page`):
amt := self.screen_size.num_lines
if strings.Contains(args, `prev`) {
amt *= -1
}
done = self.scroll_lines(amt) != 0
default:
npos := self.scroll_pos
if strings.Contains(args, `end`) {
npos = self.max_scroll_pos
} else {
npos = ScrollPos{}
}
done = npos != self.scroll_pos
self.scroll_pos = npos
}
if done {
self.draw_screen()
} else {
self.lp.Beep()
}
case `change_context`:
new_ctx := self.current_context_count
switch args {
case `all`:
new_ctx = 100000
case `default`:
new_ctx = self.original_context_count
default:
delta, _ := strconv.Atoi(args)
new_ctx += delta
}
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
}