Move the kittens Go code into the kittens folder
This commit is contained in:
@@ -1,433 +0,0 @@
|
||||
// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
|
||||
|
||||
package ask
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"kitty/tools/cli/markup"
|
||||
"kitty/tools/tui/loop"
|
||||
"kitty/tools/utils"
|
||||
"kitty/tools/utils/style"
|
||||
"kitty/tools/wcswidth"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
var _ = fmt.Print
|
||||
|
||||
type Choice struct {
|
||||
text string
|
||||
idx int
|
||||
color, letter string
|
||||
}
|
||||
|
||||
func (self Choice) prefix() string {
|
||||
return string([]rune(self.text)[:self.idx])
|
||||
}
|
||||
|
||||
func (self Choice) display_letter() string {
|
||||
return string([]rune(self.text)[self.idx])
|
||||
}
|
||||
|
||||
func (self Choice) suffix() string {
|
||||
return string([]rune(self.text)[self.idx+1:])
|
||||
}
|
||||
|
||||
type Range struct {
|
||||
start, end, y int
|
||||
}
|
||||
|
||||
func (self *Range) has_point(x, y int) bool {
|
||||
return y == self.y && self.start <= x && x <= self.end
|
||||
}
|
||||
|
||||
func truncate_at_space(text string, width int) (string, string) {
|
||||
truncated, p := wcswidth.TruncateToVisualLengthWithWidth(text, width)
|
||||
if len(truncated) == len(text) {
|
||||
return text, ""
|
||||
}
|
||||
i := strings.LastIndexByte(truncated, ' ')
|
||||
if i > 0 && p-i < 12 {
|
||||
p = i + 1
|
||||
}
|
||||
return text[:p], text[p:]
|
||||
}
|
||||
|
||||
func extra_for(width, screen_width int) int {
|
||||
return utils.Max(0, screen_width-width)/2 + 1
|
||||
}
|
||||
|
||||
func choices(o *Options) (response string, err error) {
|
||||
response = ""
|
||||
lp, err := loop.New()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
lp.MouseTrackingMode(loop.BUTTONS_ONLY_MOUSE_TRACKING)
|
||||
|
||||
prefix_style_pat := regexp.MustCompile("^(?:\x1b\\[[^m]*?m)+")
|
||||
choice_order := make([]Choice, 0, len(o.Choices))
|
||||
clickable_ranges := make(map[string][]Range, 16)
|
||||
allowed := utils.NewSet[string](utils.Max(2, len(o.Choices)))
|
||||
response_on_accept := o.Default
|
||||
switch o.Type {
|
||||
case "yesno":
|
||||
allowed.AddItems("y", "n")
|
||||
if !allowed.Has(response_on_accept) {
|
||||
response_on_accept = "y"
|
||||
}
|
||||
case "choices":
|
||||
first_choice := ""
|
||||
for i, x := range o.Choices {
|
||||
letter, text, _ := strings.Cut(x, ":")
|
||||
color := ""
|
||||
if strings.Contains(letter, ";") {
|
||||
letter, color, _ = strings.Cut(letter, ";")
|
||||
}
|
||||
letter = strings.ToLower(letter)
|
||||
idx := strings.Index(strings.ToLower(text), letter)
|
||||
idx = len([]rune(strings.ToLower(text)[:idx]))
|
||||
allowed.Add(letter)
|
||||
c := Choice{text: text, idx: idx, color: color, letter: letter}
|
||||
choice_order = append(choice_order, c)
|
||||
if i == 0 {
|
||||
first_choice = letter
|
||||
}
|
||||
}
|
||||
if !allowed.Has(response_on_accept) {
|
||||
response_on_accept = first_choice
|
||||
}
|
||||
}
|
||||
message := o.Message
|
||||
hidden_text_start_pos := -1
|
||||
hidden_text_end_pos := -1
|
||||
hidden_text := ""
|
||||
m := markup.New(true)
|
||||
replacement_text := fmt.Sprintf("Press %s or click to show", m.Green(o.UnhideKey))
|
||||
replacement_range := Range{-1, -1, -1}
|
||||
if message != "" && o.HiddenTextPlaceholder != "" {
|
||||
hidden_text_start_pos = strings.Index(message, o.HiddenTextPlaceholder)
|
||||
if hidden_text_start_pos > -1 {
|
||||
raw, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("Failed to read hidden text from STDIN: %w", err)
|
||||
}
|
||||
hidden_text = strings.TrimRightFunc(utils.UnsafeBytesToString(raw), unicode.IsSpace)
|
||||
hidden_text_end_pos = hidden_text_start_pos + len(replacement_text)
|
||||
suffix := message[hidden_text_start_pos+len(o.HiddenTextPlaceholder):]
|
||||
message = message[:hidden_text_start_pos] + replacement_text + suffix
|
||||
}
|
||||
}
|
||||
|
||||
draw_long_text := func(screen_width int, text string, msg_lines []string) []string {
|
||||
if text == "" {
|
||||
msg_lines = append(msg_lines, "")
|
||||
} else {
|
||||
width := screen_width - 2
|
||||
prefix := prefix_style_pat.FindString(text)
|
||||
for text != "" {
|
||||
var t string
|
||||
t, text = truncate_at_space(text, width)
|
||||
t = strings.TrimSpace(t)
|
||||
msg_lines = append(msg_lines, strings.Repeat(" ", extra_for(wcswidth.Stringwidth(t), width))+m.Bold(prefix+t))
|
||||
}
|
||||
}
|
||||
return msg_lines
|
||||
}
|
||||
|
||||
ctx := style.Context{AllowEscapeCodes: true}
|
||||
|
||||
draw_choice_boxes := func(y, screen_width, screen_height int, choices ...Choice) {
|
||||
clickable_ranges = map[string][]Range{}
|
||||
width := screen_width - 2
|
||||
current_line_length := 0
|
||||
type Item struct{ letter, text string }
|
||||
type Line = []Item
|
||||
var current_line Line
|
||||
lines := make([]Line, 0, 32)
|
||||
sep := " "
|
||||
sep_sz := len(sep) + 2 // for the borders
|
||||
|
||||
for _, choice := range choices {
|
||||
clickable_ranges[choice.letter] = make([]Range, 0, 4)
|
||||
text := " " + choice.prefix()
|
||||
color := choice.color
|
||||
if choice.color == "" {
|
||||
color = "green"
|
||||
}
|
||||
text += ctx.SprintFunc("fg=" + color)(choice.display_letter())
|
||||
text += choice.suffix() + " "
|
||||
sz := wcswidth.Stringwidth(text)
|
||||
if sz+sep_sz+current_line_length > width {
|
||||
lines = append(lines, current_line)
|
||||
current_line = nil
|
||||
current_line_length = 0
|
||||
}
|
||||
current_line = append(current_line, Item{choice.letter, text})
|
||||
current_line_length += sz + sep_sz
|
||||
}
|
||||
if len(current_line) > 0 {
|
||||
lines = append(lines, current_line)
|
||||
}
|
||||
|
||||
highlight := func(text string) string {
|
||||
return m.Yellow(text)
|
||||
}
|
||||
|
||||
top := func(text string, highlight_frame bool) (ans string) {
|
||||
ans = "╭" + strings.Repeat("─", wcswidth.Stringwidth(text)) + "╮"
|
||||
if highlight_frame {
|
||||
ans = highlight(ans)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
middle := func(text string, highlight_frame bool) (ans string) {
|
||||
f := "│"
|
||||
if highlight_frame {
|
||||
f = highlight(f)
|
||||
}
|
||||
return f + text + f
|
||||
}
|
||||
|
||||
bottom := func(text string, highlight_frame bool) (ans string) {
|
||||
ans = "╰" + strings.Repeat("─", wcswidth.Stringwidth(text)) + "╯"
|
||||
if highlight_frame {
|
||||
ans = highlight(ans)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
print_line := func(add_borders func(string, bool) string, is_last bool, items ...Item) {
|
||||
type Position struct {
|
||||
letter string
|
||||
x, size int
|
||||
}
|
||||
texts := make([]string, 0, 8)
|
||||
positions := make([]Position, 0, 8)
|
||||
x := 0
|
||||
for _, item := range items {
|
||||
text := item.text
|
||||
positions = append(positions, Position{item.letter, x, wcswidth.Stringwidth(text) + 2})
|
||||
text = add_borders(text, item.letter == response_on_accept)
|
||||
text += sep
|
||||
x += wcswidth.Stringwidth(text)
|
||||
texts = append(texts, text)
|
||||
}
|
||||
line := strings.TrimRightFunc(strings.Join(texts, ""), unicode.IsSpace)
|
||||
offset := extra_for(wcswidth.Stringwidth(line), width)
|
||||
for _, pos := range positions {
|
||||
x = pos.x
|
||||
x += offset
|
||||
clickable_ranges[pos.letter] = append(clickable_ranges[pos.letter], Range{x, x + pos.size - 1, y})
|
||||
}
|
||||
end := "\r\n"
|
||||
if is_last {
|
||||
end = ""
|
||||
}
|
||||
lp.QueueWriteString(strings.Repeat(" ", offset) + line + end)
|
||||
y++
|
||||
}
|
||||
lp.AllowLineWrapping(false)
|
||||
defer func() { lp.AllowLineWrapping(true) }()
|
||||
for i, boxed_line := range lines {
|
||||
print_line(top, false, boxed_line...)
|
||||
print_line(middle, false, boxed_line...)
|
||||
is_last := i == len(lines)-1
|
||||
print_line(bottom, is_last, boxed_line...)
|
||||
}
|
||||
}
|
||||
|
||||
draw_yesno := func(y, screen_width, screen_height int) {
|
||||
yes := m.Green("Y") + "es"
|
||||
no := m.BrightRed("N") + "o"
|
||||
if y+3 <= screen_height {
|
||||
draw_choice_boxes(y, screen_width, screen_height, Choice{"Yes", 0, "green", "y"}, Choice{"No", 0, "red", "n"})
|
||||
} else {
|
||||
sep := strings.Repeat(" ", 3)
|
||||
text := yes + sep + no
|
||||
w := wcswidth.Stringwidth(text)
|
||||
x := extra_for(w, screen_width-2)
|
||||
nx := x + wcswidth.Stringwidth(yes) + len(sep)
|
||||
clickable_ranges = map[string][]Range{
|
||||
"y": {{x, x + wcswidth.Stringwidth(yes) - 1, y}},
|
||||
"n": {{nx, nx + wcswidth.Stringwidth(no) - 1, y}},
|
||||
}
|
||||
lp.QueueWriteString(strings.Repeat(" ", x) + text)
|
||||
}
|
||||
}
|
||||
|
||||
draw_choice := func(y, screen_width, screen_height int) {
|
||||
if y+3 <= screen_height {
|
||||
draw_choice_boxes(y, screen_width, screen_height, choice_order...)
|
||||
return
|
||||
}
|
||||
clickable_ranges = map[string][]Range{}
|
||||
current_line := ""
|
||||
current_ranges := map[string]int{}
|
||||
width := screen_width - 2
|
||||
|
||||
commit_line := func(add_newline bool) {
|
||||
x := extra_for(wcswidth.Stringwidth(current_line), width)
|
||||
text := strings.Repeat(" ", x) + current_line
|
||||
if add_newline {
|
||||
lp.Println(text)
|
||||
} else {
|
||||
lp.QueueWriteString(text)
|
||||
}
|
||||
for letter, sz := range current_ranges {
|
||||
clickable_ranges[letter] = []Range{{x, x + sz - 3, y}}
|
||||
x += sz
|
||||
}
|
||||
current_ranges = map[string]int{}
|
||||
y++
|
||||
current_line = ""
|
||||
}
|
||||
for _, choice := range choice_order {
|
||||
text := choice.prefix()
|
||||
spec := ""
|
||||
if choice.color != "" {
|
||||
spec = "fg=" + choice.color
|
||||
} else {
|
||||
spec = "fg=green"
|
||||
}
|
||||
if choice.letter == response_on_accept {
|
||||
spec += " u=straight"
|
||||
}
|
||||
text += ctx.SprintFunc(spec)(choice.display_letter())
|
||||
text += choice.suffix()
|
||||
text += " "
|
||||
sz := wcswidth.Stringwidth(text)
|
||||
if sz+wcswidth.Stringwidth(current_line) >= width {
|
||||
commit_line(true)
|
||||
}
|
||||
current_line += text
|
||||
current_ranges[choice.letter] = sz
|
||||
}
|
||||
if current_line != "" {
|
||||
commit_line(false)
|
||||
}
|
||||
}
|
||||
|
||||
draw_screen := func() error {
|
||||
lp.StartAtomicUpdate()
|
||||
defer lp.EndAtomicUpdate()
|
||||
lp.ClearScreen()
|
||||
msg_lines := make([]string, 0, 8)
|
||||
sz, err := lp.ScreenSize()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if message != "" {
|
||||
scanner := utils.NewLineScanner(message)
|
||||
for scanner.Scan() {
|
||||
msg_lines = draw_long_text(int(sz.WidthCells), scanner.Text(), msg_lines)
|
||||
}
|
||||
}
|
||||
y := int(sz.HeightCells) - len(msg_lines)
|
||||
y = utils.Max(0, (y/2)-2)
|
||||
lp.QueueWriteString(strings.Repeat("\r\n", y))
|
||||
for _, line := range msg_lines {
|
||||
if replacement_text != "" {
|
||||
idx := strings.Index(line, replacement_text)
|
||||
if idx > -1 {
|
||||
x := wcswidth.Stringwidth(line[:idx])
|
||||
replacement_range = Range{x, x + wcswidth.Stringwidth(replacement_text), y}
|
||||
}
|
||||
}
|
||||
lp.Println(line)
|
||||
y++
|
||||
}
|
||||
if sz.HeightCells > 2 {
|
||||
lp.Println()
|
||||
y++
|
||||
}
|
||||
switch o.Type {
|
||||
case "yesno":
|
||||
draw_yesno(y, int(sz.WidthCells), int(sz.HeightCells))
|
||||
case "choices":
|
||||
draw_choice(y, int(sz.WidthCells), int(sz.HeightCells))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
unhide := func() {
|
||||
if hidden_text != "" && message != "" {
|
||||
message = message[:hidden_text_start_pos] + hidden_text + message[hidden_text_end_pos:]
|
||||
hidden_text = ""
|
||||
draw_screen()
|
||||
}
|
||||
}
|
||||
|
||||
lp.OnInitialize = func() (string, error) {
|
||||
lp.SetCursorVisible(false)
|
||||
return "", draw_screen()
|
||||
}
|
||||
|
||||
lp.OnFinalize = func() string {
|
||||
lp.SetCursorVisible(true)
|
||||
return ""
|
||||
}
|
||||
|
||||
lp.OnText = func(text string, from_key_event, in_bracketed_paste bool) error {
|
||||
text = strings.ToLower(text)
|
||||
if allowed.Has(text) {
|
||||
response = text
|
||||
lp.Quit(0)
|
||||
} else if hidden_text != "" && text == o.UnhideKey {
|
||||
unhide()
|
||||
} else if o.Type == "yesno" {
|
||||
lp.Quit(1)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
lp.OnKeyEvent = func(ev *loop.KeyEvent) error {
|
||||
if ev.MatchesPressOrRepeat("esc") || ev.MatchesPressOrRepeat("ctrl+c") {
|
||||
ev.Handled = true
|
||||
lp.Quit(1)
|
||||
} else if ev.MatchesPressOrRepeat("enter") {
|
||||
ev.Handled = true
|
||||
response = response_on_accept
|
||||
lp.Quit(0)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
lp.OnMouseEvent = func(ev *loop.MouseEvent) error {
|
||||
if ev.Event_type == loop.MOUSE_CLICK {
|
||||
for letter, ranges := range clickable_ranges {
|
||||
for _, r := range ranges {
|
||||
if r.has_point(ev.Cell.X, ev.Cell.Y) {
|
||||
response = letter
|
||||
lp.Quit(0)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
if hidden_text != "" && replacement_range.has_point(ev.Cell.X, ev.Cell.Y) {
|
||||
unhide()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
lp.OnResize = func(old, news loop.ScreenSize) error {
|
||||
return draw_screen()
|
||||
}
|
||||
|
||||
err = lp.Run()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
ds := lp.DeathSignalName()
|
||||
if ds != "" {
|
||||
fmt.Println("Killed by signal: ", ds)
|
||||
lp.KillIfSignalled()
|
||||
return "", fmt.Errorf("Filled by signal: %s", ds)
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
|
||||
|
||||
package ask
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"kitty/tools/tui/loop"
|
||||
"kitty/tools/tui/readline"
|
||||
"kitty/tools/utils"
|
||||
)
|
||||
|
||||
var _ = fmt.Print
|
||||
|
||||
func get_line(o *Options) (result string, err error) {
|
||||
lp, err := loop.New(loop.NoAlternateScreen, loop.NoRestoreColors)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
cwd, _ := os.Getwd()
|
||||
ropts := readline.RlInit{Prompt: o.Prompt}
|
||||
if o.Name != "" {
|
||||
base := filepath.Join(utils.CacheDir(), "ask")
|
||||
ropts.HistoryPath = filepath.Join(base, o.Name+".history.json")
|
||||
os.MkdirAll(base, 0o755)
|
||||
}
|
||||
rl := readline.New(lp, ropts)
|
||||
if o.Default != "" {
|
||||
rl.SetText(o.Default)
|
||||
}
|
||||
lp.OnInitialize = func() (string, error) {
|
||||
rl.Start()
|
||||
return "", nil
|
||||
}
|
||||
lp.OnFinalize = func() string { rl.End(); return "" }
|
||||
|
||||
lp.OnResumeFromStop = func() error {
|
||||
rl.Start()
|
||||
return nil
|
||||
}
|
||||
|
||||
lp.OnResize = rl.OnResize
|
||||
|
||||
lp.OnKeyEvent = func(event *loop.KeyEvent) error {
|
||||
if event.MatchesPressOrRepeat("ctrl+c") {
|
||||
return fmt.Errorf("Canceled by user")
|
||||
}
|
||||
err := rl.OnKeyEvent(event)
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
lp.Quit(0)
|
||||
return nil
|
||||
}
|
||||
if err == readline.ErrAcceptInput {
|
||||
hi := readline.HistoryItem{Timestamp: time.Now(), Cmd: rl.AllText(), ExitCode: 0, Cwd: cwd}
|
||||
rl.AddHistoryItem(hi)
|
||||
result = rl.AllText()
|
||||
lp.Quit(0)
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if event.Handled {
|
||||
rl.Redraw()
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
lp.OnText = func(text string, from_key_event, in_bracketed_paste bool) error {
|
||||
err := rl.OnText(text, from_key_event, in_bracketed_paste)
|
||||
if err == nil {
|
||||
rl.Redraw()
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
err = lp.Run()
|
||||
rl.Shutdown()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
ds := lp.DeathSignalName()
|
||||
if ds != "" {
|
||||
return "", fmt.Errorf("Killed by signal: %s", ds)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
|
||||
|
||||
package ask
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"kitty/tools/cli"
|
||||
"kitty/tools/cli/markup"
|
||||
"kitty/tools/tui"
|
||||
)
|
||||
|
||||
var _ = fmt.Print
|
||||
|
||||
type Response struct {
|
||||
Items []string `json:"items"`
|
||||
Response string `json:"response"`
|
||||
}
|
||||
|
||||
func show_message(msg string) {
|
||||
if msg != "" {
|
||||
m := markup.New(true)
|
||||
fmt.Println(m.Bold(msg))
|
||||
}
|
||||
}
|
||||
|
||||
func main(_ *cli.Command, o *Options, args []string) (rc int, err error) {
|
||||
output := tui.KittenOutputSerializer()
|
||||
result := &Response{Items: args}
|
||||
if len(o.Prompt) > 2 && o.Prompt[0] == o.Prompt[len(o.Prompt)-1] && (o.Prompt[0] == '"' || o.Prompt[0] == '\'') {
|
||||
o.Prompt = o.Prompt[1 : len(o.Prompt)-1]
|
||||
}
|
||||
switch o.Type {
|
||||
case "yesno", "choices":
|
||||
result.Response, err = choices(o)
|
||||
if err != nil {
|
||||
return 1, err
|
||||
}
|
||||
case "password":
|
||||
show_message(o.Message)
|
||||
pw, err := tui.ReadPassword(o.Prompt, false)
|
||||
if err != nil {
|
||||
if errors.Is(err, tui.Canceled) {
|
||||
pw = ""
|
||||
} else {
|
||||
return 1, err
|
||||
}
|
||||
}
|
||||
result.Response = pw
|
||||
case "line":
|
||||
show_message(o.Message)
|
||||
result.Response, err = get_line(o)
|
||||
if err != nil {
|
||||
return 1, err
|
||||
}
|
||||
default:
|
||||
return 1, fmt.Errorf("Unknown type: %s", o.Type)
|
||||
}
|
||||
s, err := output(result)
|
||||
if err != nil {
|
||||
return 1, err
|
||||
}
|
||||
_, err = fmt.Println(s)
|
||||
if err != nil {
|
||||
return 1, err
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func EntryPoint(parent *cli.Command) {
|
||||
create_cmd(parent, main)
|
||||
}
|
||||
@@ -1,237 +0,0 @@
|
||||
// License: GPLv3 Copyright: 2022, Kovid Goyal, <kovid at kovidgoyal.net>
|
||||
|
||||
package clipboard
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"kitty/tools/tty"
|
||||
"kitty/tools/tui/loop"
|
||||
"kitty/tools/utils"
|
||||
)
|
||||
|
||||
var _ = fmt.Print
|
||||
|
||||
var _ = fmt.Print
|
||||
|
||||
func encode_read_from_clipboard(use_primary bool) string {
|
||||
dest := "c"
|
||||
if use_primary {
|
||||
dest = "p"
|
||||
}
|
||||
return fmt.Sprintf("\x1b]52;%s;?\x1b\\", dest)
|
||||
}
|
||||
|
||||
type base64_streaming_enc struct {
|
||||
output func(string) loop.IdType
|
||||
last_written_id loop.IdType
|
||||
}
|
||||
|
||||
func (self *base64_streaming_enc) Write(p []byte) (int, error) {
|
||||
if len(p) > 0 {
|
||||
self.last_written_id = self.output(string(p))
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
var ErrTooMuchPipedData = errors.New("Too much piped data")
|
||||
|
||||
func read_all_with_max_size(r io.Reader, max_size int) ([]byte, error) {
|
||||
b := make([]byte, 0, utils.Min(8192, max_size))
|
||||
for {
|
||||
if len(b) == cap(b) {
|
||||
new_size := utils.Min(2*cap(b), max_size)
|
||||
if new_size <= cap(b) {
|
||||
return b, ErrTooMuchPipedData
|
||||
}
|
||||
b = append(make([]byte, 0, new_size), b...)
|
||||
}
|
||||
n, err := r.Read(b[len(b):cap(b)])
|
||||
b = b[:len(b)+n]
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
err = nil
|
||||
}
|
||||
return b, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func preread_stdin() (data_src io.Reader, tempfile *os.File, err error) {
|
||||
// we pre-read STDIN because otherwise if the output of a command is being piped in
|
||||
// and that command itself transmits on the tty we will break. For example
|
||||
// kitten @ ls | kitten clipboard
|
||||
var stdin_data []byte
|
||||
stdin_data, err = read_all_with_max_size(os.Stdin, 2*1024*1024)
|
||||
if err == nil {
|
||||
os.Stdin.Close()
|
||||
} else if err != ErrTooMuchPipedData {
|
||||
os.Stdin.Close()
|
||||
err = fmt.Errorf("Failed to read from STDIN pipe with error: %w", err)
|
||||
return
|
||||
}
|
||||
if err == ErrTooMuchPipedData {
|
||||
tempfile, err = utils.CreateAnonymousTemp("")
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("Failed to create a temporary from STDIN pipe with error: %w", err)
|
||||
}
|
||||
tempfile.Write(stdin_data)
|
||||
_, err = io.Copy(tempfile, os.Stdin)
|
||||
os.Stdin.Close()
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("Failed to copy data from STDIN pipe to temp file with error: %w", err)
|
||||
}
|
||||
tempfile.Seek(0, os.SEEK_SET)
|
||||
data_src = tempfile
|
||||
} else if stdin_data != nil {
|
||||
data_src = bytes.NewBuffer(stdin_data)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func run_plain_text_loop(opts *Options) (err error) {
|
||||
stdin_is_tty := tty.IsTerminal(os.Stdin.Fd())
|
||||
var data_src io.Reader
|
||||
var tempfile *os.File
|
||||
if !stdin_is_tty {
|
||||
data_src, tempfile, err = preread_stdin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tempfile != nil {
|
||||
defer tempfile.Close()
|
||||
}
|
||||
}
|
||||
lp, err := loop.New(loop.NoAlternateScreen, loop.NoRestoreColors, loop.NoMouseTracking)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
dest := "c"
|
||||
if opts.UsePrimary {
|
||||
dest = "p"
|
||||
}
|
||||
|
||||
send_to_loop := func(data string) loop.IdType {
|
||||
return lp.QueueWriteString(data)
|
||||
}
|
||||
enc_writer := base64_streaming_enc{output: send_to_loop}
|
||||
enc := base64.NewEncoder(base64.StdEncoding, &enc_writer)
|
||||
transmitting := true
|
||||
|
||||
after_read_from_stdin := func() {
|
||||
transmitting = false
|
||||
if opts.GetClipboard {
|
||||
lp.QueueWriteString(encode_read_from_clipboard(opts.UsePrimary))
|
||||
} else if opts.WaitForCompletion {
|
||||
lp.QueueWriteString("\x1bP+q544e\x1b\\")
|
||||
} else {
|
||||
lp.Quit(0)
|
||||
}
|
||||
}
|
||||
|
||||
buf := make([]byte, 8192)
|
||||
write_one_chunk := func() error {
|
||||
n, err := data_src.Read(buf[:cap(buf)])
|
||||
if err != nil && !errors.Is(err, io.EOF) {
|
||||
send_to_loop("\x1b\\")
|
||||
return err
|
||||
}
|
||||
if n > 0 {
|
||||
enc.Write(buf[:n])
|
||||
}
|
||||
if errors.Is(err, io.EOF) {
|
||||
enc.Close()
|
||||
send_to_loop("\x1b\\")
|
||||
after_read_from_stdin()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
lp.OnInitialize = func() (string, error) {
|
||||
if data_src != nil {
|
||||
send_to_loop(fmt.Sprintf("\x1b]52;%s;", dest))
|
||||
return "", write_one_chunk()
|
||||
}
|
||||
after_read_from_stdin()
|
||||
return "", nil
|
||||
}
|
||||
|
||||
lp.OnWriteComplete = func(id loop.IdType) error {
|
||||
if id == enc_writer.last_written_id {
|
||||
return write_one_chunk()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var clipboard_contents []byte
|
||||
|
||||
lp.OnEscapeCode = func(etype loop.EscapeCodeType, data []byte) (err error) {
|
||||
switch etype {
|
||||
case loop.DCS:
|
||||
if strings.HasPrefix(utils.UnsafeBytesToString(data), "1+r") {
|
||||
lp.Quit(0)
|
||||
}
|
||||
case loop.OSC:
|
||||
q := utils.UnsafeBytesToString(data)
|
||||
if strings.HasPrefix(q, "52;") {
|
||||
parts := strings.SplitN(q, ";", 3)
|
||||
if len(parts) < 3 {
|
||||
lp.Quit(0)
|
||||
return
|
||||
}
|
||||
data, err := base64.StdEncoding.DecodeString(parts[2])
|
||||
if err != nil {
|
||||
return fmt.Errorf("Invalid base64 encoded data from terminal with error: %w", err)
|
||||
}
|
||||
clipboard_contents = data
|
||||
lp.Quit(0)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
esc_count := 0
|
||||
lp.OnKeyEvent = func(event *loop.KeyEvent) error {
|
||||
if event.MatchesPressOrRepeat("ctrl+c") || event.MatchesPressOrRepeat("esc") {
|
||||
if transmitting {
|
||||
return nil
|
||||
}
|
||||
event.Handled = true
|
||||
esc_count++
|
||||
if esc_count < 2 {
|
||||
key := "Esc"
|
||||
if event.MatchesPressOrRepeat("ctrl+c") {
|
||||
key = "Ctrl+C"
|
||||
}
|
||||
lp.QueueWriteString(fmt.Sprintf("Waiting for response from terminal, press %s again to abort. This could cause garbage to be spewed to the screen.\r\n", key))
|
||||
} else {
|
||||
return fmt.Errorf("Aborted by user!")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
err = lp.Run()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
ds := lp.DeathSignalName()
|
||||
if ds != "" {
|
||||
fmt.Println("Killed by signal: ", ds)
|
||||
lp.KillIfSignalled()
|
||||
return
|
||||
}
|
||||
if len(clipboard_contents) > 0 {
|
||||
_, err = os.Stdout.Write(clipboard_contents)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("Failed to write to STDOUT with error: %w", err)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
// License: GPLv3 Copyright: 2022, Kovid Goyal, <kovid at kovidgoyal.net>
|
||||
|
||||
package clipboard
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"kitty/tools/cli"
|
||||
)
|
||||
|
||||
func run_mime_loop(opts *Options, args []string) (err error) {
|
||||
cwd, err = os.Getwd()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if opts.GetClipboard {
|
||||
return run_get_loop(opts, args)
|
||||
}
|
||||
return run_set_loop(opts, args)
|
||||
}
|
||||
|
||||
func clipboard_main(cmd *cli.Command, opts *Options, args []string) (rc int, err error) {
|
||||
if len(args) > 0 {
|
||||
return 0, run_mime_loop(opts, args)
|
||||
}
|
||||
|
||||
return 0, run_plain_text_loop(opts)
|
||||
}
|
||||
|
||||
func EntryPoint(parent *cli.Command) {
|
||||
create_cmd(parent, clipboard_main)
|
||||
}
|
||||
@@ -1,456 +0,0 @@
|
||||
// License: GPLv3 Copyright: 2022, Kovid Goyal, <kovid at kovidgoyal.net>
|
||||
|
||||
package clipboard
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"image"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"kitty/tools/tty"
|
||||
"kitty/tools/tui/loop"
|
||||
"kitty/tools/utils"
|
||||
"kitty/tools/utils/images"
|
||||
|
||||
"golang.org/x/exp/maps"
|
||||
"golang.org/x/exp/slices"
|
||||
)
|
||||
|
||||
var _ = fmt.Print
|
||||
var cwd string
|
||||
|
||||
const OSC_NUMBER = "5522"
|
||||
|
||||
type Output struct {
|
||||
arg string
|
||||
ext string
|
||||
arg_is_stream bool
|
||||
mime_type string
|
||||
remote_mime_type string
|
||||
image_needs_conversion bool
|
||||
is_stream bool
|
||||
dest_is_tty bool
|
||||
dest *os.File
|
||||
err error
|
||||
started bool
|
||||
all_data_received bool
|
||||
}
|
||||
|
||||
func (self *Output) cleanup() {
|
||||
if self.dest != nil {
|
||||
self.dest.Close()
|
||||
if !self.is_stream {
|
||||
os.Remove(self.dest.Name())
|
||||
}
|
||||
self.dest = nil
|
||||
}
|
||||
}
|
||||
|
||||
func (self *Output) add_data(data []byte) {
|
||||
if self.err != nil {
|
||||
return
|
||||
}
|
||||
if self.dest == nil {
|
||||
if !self.image_needs_conversion && self.arg_is_stream {
|
||||
self.is_stream = true
|
||||
self.dest = os.Stdout
|
||||
if self.arg == "/dev/stderr" {
|
||||
self.dest = os.Stderr
|
||||
}
|
||||
self.dest_is_tty = tty.IsTerminal(self.dest.Fd())
|
||||
} else {
|
||||
d := cwd
|
||||
if strings.ContainsRune(self.arg, os.PathSeparator) && !self.arg_is_stream {
|
||||
d = filepath.Dir(self.arg)
|
||||
}
|
||||
f, err := os.CreateTemp(d, "."+filepath.Base(self.arg))
|
||||
if err != nil {
|
||||
self.err = err
|
||||
return
|
||||
}
|
||||
self.dest = f
|
||||
}
|
||||
self.started = true
|
||||
}
|
||||
if self.dest_is_tty {
|
||||
data = bytes.ReplaceAll(data, utils.UnsafeStringToBytes("\n"), utils.UnsafeStringToBytes("\r\n"))
|
||||
}
|
||||
_, self.err = self.dest.Write(data)
|
||||
}
|
||||
|
||||
func (self *Output) write_image(img image.Image) (err error) {
|
||||
var output *os.File
|
||||
if self.arg_is_stream {
|
||||
output = os.Stdout
|
||||
if self.arg == "/dev/stderr" {
|
||||
output = os.Stderr
|
||||
}
|
||||
} else {
|
||||
output, err = os.Create(self.arg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
defer func() {
|
||||
output.Close()
|
||||
if err != nil && !self.arg_is_stream {
|
||||
os.Remove(output.Name())
|
||||
}
|
||||
}()
|
||||
return images.Encode(output, img, self.mime_type)
|
||||
}
|
||||
|
||||
func (self *Output) commit() {
|
||||
if self.err != nil {
|
||||
return
|
||||
}
|
||||
if self.image_needs_conversion {
|
||||
self.dest.Seek(0, os.SEEK_SET)
|
||||
img, _, err := image.Decode(self.dest)
|
||||
self.dest.Close()
|
||||
os.Remove(self.dest.Name())
|
||||
if err == nil {
|
||||
err = self.write_image(img)
|
||||
}
|
||||
if err != nil {
|
||||
self.err = fmt.Errorf("Failed to encode image data to %s with error: %w", self.mime_type, err)
|
||||
}
|
||||
} else {
|
||||
self.dest.Close()
|
||||
if !self.is_stream {
|
||||
f, err := os.OpenFile(self.arg, os.O_CREATE|os.O_RDONLY, 0666)
|
||||
if err == nil {
|
||||
fi, err := f.Stat()
|
||||
if err == nil {
|
||||
self.dest.Chmod(fi.Mode().Perm())
|
||||
}
|
||||
f.Close()
|
||||
os.Remove(f.Name())
|
||||
}
|
||||
self.err = os.Rename(self.dest.Name(), self.arg)
|
||||
if self.err != nil {
|
||||
os.Remove(self.dest.Name())
|
||||
self.err = fmt.Errorf("Failed to rename temporary file used for downloading to destination: %s with error: %w", self.arg, self.err)
|
||||
}
|
||||
}
|
||||
}
|
||||
self.dest = nil
|
||||
}
|
||||
|
||||
func (self *Output) assign_mime_type(available_mimes []string, aliases map[string][]string) (err error) {
|
||||
if self.mime_type == "." {
|
||||
self.remote_mime_type = "."
|
||||
return
|
||||
}
|
||||
if slices.Contains(available_mimes, self.mime_type) {
|
||||
self.remote_mime_type = self.mime_type
|
||||
return
|
||||
}
|
||||
if len(aliases[self.mime_type]) > 0 {
|
||||
for _, alias := range aliases[self.mime_type] {
|
||||
if slices.Contains(available_mimes, alias) {
|
||||
self.remote_mime_type = alias
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, mt := range available_mimes {
|
||||
if matched, _ := filepath.Match(self.mime_type, mt); matched {
|
||||
self.remote_mime_type = mt
|
||||
return
|
||||
}
|
||||
}
|
||||
if images.EncodableImageTypes[self.mime_type] {
|
||||
for _, mt := range available_mimes {
|
||||
if images.DecodableImageTypes[mt] {
|
||||
self.remote_mime_type = mt
|
||||
self.image_needs_conversion = true
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
if is_textual_mime(self.mime_type) {
|
||||
for _, mt := range available_mimes {
|
||||
if mt == "text/plain" {
|
||||
self.remote_mime_type = mt
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("The MIME type %s for %s not available on the clipboard", self.mime_type, self.arg)
|
||||
}
|
||||
|
||||
func escape_metadata_value(k, x string) (ans string) {
|
||||
if k == "mime" {
|
||||
x = base64.StdEncoding.EncodeToString(utils.UnsafeStringToBytes(x))
|
||||
}
|
||||
return x
|
||||
}
|
||||
|
||||
func unescape_metadata_value(k, x string) (ans string) {
|
||||
if k == "mime" {
|
||||
b, err := base64.StdEncoding.DecodeString(x)
|
||||
if err == nil {
|
||||
x = string(b)
|
||||
}
|
||||
}
|
||||
return x
|
||||
}
|
||||
|
||||
func encode_bytes(metadata map[string]string, payload []byte) string {
|
||||
ans := strings.Builder{}
|
||||
ans.Grow(2048)
|
||||
ans.WriteString("\x1b]")
|
||||
ans.WriteString(OSC_NUMBER)
|
||||
ans.WriteString(";")
|
||||
for k, v := range metadata {
|
||||
if !strings.HasSuffix(ans.String(), ";") {
|
||||
ans.WriteString(":")
|
||||
}
|
||||
ans.WriteString(k)
|
||||
ans.WriteString("=")
|
||||
ans.WriteString(escape_metadata_value(k, v))
|
||||
}
|
||||
if len(payload) > 0 {
|
||||
ans.WriteString(";")
|
||||
ans.WriteString(base64.StdEncoding.EncodeToString(payload))
|
||||
}
|
||||
ans.WriteString("\x1b\\")
|
||||
return ans.String()
|
||||
}
|
||||
|
||||
func encode(metadata map[string]string, payload string) string {
|
||||
return encode_bytes(metadata, utils.UnsafeStringToBytes(payload))
|
||||
}
|
||||
|
||||
func error_from_status(status string) error {
|
||||
switch status {
|
||||
case "ENOSYS":
|
||||
return fmt.Errorf("no primary selection available on this system")
|
||||
case "EPERM":
|
||||
return fmt.Errorf("permission denied")
|
||||
case "EBUSY":
|
||||
return fmt.Errorf("a temporary error occurred, try again later.")
|
||||
default:
|
||||
return fmt.Errorf("%s", status)
|
||||
}
|
||||
}
|
||||
|
||||
func parse_escape_code(etype loop.EscapeCodeType, data []byte) (metadata map[string]string, payload []byte, err error) {
|
||||
if etype != loop.OSC || !bytes.HasPrefix(data, utils.UnsafeStringToBytes(OSC_NUMBER+";")) {
|
||||
return
|
||||
}
|
||||
parts := bytes.SplitN(data, utils.UnsafeStringToBytes(";"), 3)
|
||||
metadata = make(map[string]string)
|
||||
if len(parts) > 2 && len(parts[2]) > 0 {
|
||||
payload, err = base64.StdEncoding.DecodeString(utils.UnsafeBytesToString(parts[2]))
|
||||
if err != nil {
|
||||
err = fmt.Errorf("Received OSC %s packet from terminal with invalid base64 encoded payload", OSC_NUMBER)
|
||||
return
|
||||
}
|
||||
}
|
||||
if len(parts) > 1 {
|
||||
for _, record := range bytes.Split(parts[1], utils.UnsafeStringToBytes(":")) {
|
||||
rp := bytes.SplitN(record, utils.UnsafeStringToBytes("="), 2)
|
||||
v := ""
|
||||
if len(rp) == 2 {
|
||||
v = string(rp[1])
|
||||
}
|
||||
k := string(rp[0])
|
||||
metadata[k] = unescape_metadata_value(k, v)
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func parse_aliases(raw []string) (map[string][]string, error) {
|
||||
ans := make(map[string][]string, len(raw))
|
||||
for _, x := range raw {
|
||||
k, v, found := strings.Cut(x, "=")
|
||||
if !found {
|
||||
return nil, fmt.Errorf("%s is not valid MIME alias specification", x)
|
||||
}
|
||||
ans[k] = append(ans[k], v)
|
||||
ans[v] = append(ans[v], k)
|
||||
}
|
||||
return ans, nil
|
||||
}
|
||||
|
||||
func run_get_loop(opts *Options, args []string) (err error) {
|
||||
lp, err := loop.New(loop.NoAlternateScreen, loop.NoRestoreColors, loop.NoMouseTracking)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var available_mimes []string
|
||||
var wg sync.WaitGroup
|
||||
var getting_data_for string
|
||||
requested_mimes := make(map[string]*Output)
|
||||
reading_available_mimes := true
|
||||
outputs := make([]*Output, len(args))
|
||||
aliases, merr := parse_aliases(opts.Alias)
|
||||
if merr != nil {
|
||||
return merr
|
||||
}
|
||||
|
||||
for i, arg := range args {
|
||||
outputs[i] = &Output{arg: arg, arg_is_stream: arg == "/dev/stdout" || arg == "/dev/stderr", ext: filepath.Ext(arg)}
|
||||
if len(opts.Mime) > i {
|
||||
outputs[i].mime_type = opts.Mime[i]
|
||||
} else {
|
||||
if outputs[i].arg_is_stream {
|
||||
outputs[i].mime_type = "text/plain"
|
||||
} else {
|
||||
outputs[i].mime_type = utils.GuessMimeType(outputs[i].arg)
|
||||
}
|
||||
}
|
||||
if outputs[i].mime_type == "" {
|
||||
return fmt.Errorf("Could not detect the MIME type for: %s use --mime to specify it manually", arg)
|
||||
}
|
||||
}
|
||||
|
||||
defer func() {
|
||||
for _, o := range outputs {
|
||||
if o.dest != nil {
|
||||
o.cleanup()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
basic_metadata := map[string]string{"type": "read"}
|
||||
if opts.UsePrimary {
|
||||
basic_metadata["loc"] = "primary"
|
||||
}
|
||||
|
||||
lp.OnInitialize = func() (string, error) {
|
||||
lp.QueueWriteString(encode(basic_metadata, "."))
|
||||
return "", nil
|
||||
}
|
||||
|
||||
lp.OnEscapeCode = func(etype loop.EscapeCodeType, data []byte) (err error) {
|
||||
metadata, payload, err := parse_escape_code(etype, data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if metadata == nil {
|
||||
return nil
|
||||
}
|
||||
if reading_available_mimes {
|
||||
switch metadata["status"] {
|
||||
case "DATA":
|
||||
available_mimes = strings.Split(utils.UnsafeBytesToString(payload), " ")
|
||||
case "OK":
|
||||
case "DONE":
|
||||
reading_available_mimes = false
|
||||
if len(available_mimes) == 0 {
|
||||
return fmt.Errorf("The clipboard is empty")
|
||||
}
|
||||
for _, o := range outputs {
|
||||
err = o.assign_mime_type(available_mimes, aliases)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if o.remote_mime_type == "." {
|
||||
o.started = true
|
||||
o.add_data(utils.UnsafeStringToBytes(strings.Join(available_mimes, "\n")))
|
||||
o.all_data_received = true
|
||||
} else {
|
||||
requested_mimes[o.remote_mime_type] = o
|
||||
}
|
||||
}
|
||||
if len(requested_mimes) > 0 {
|
||||
lp.QueueWriteString(encode(basic_metadata, strings.Join(maps.Keys(requested_mimes), " ")))
|
||||
} else {
|
||||
lp.Quit(0)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("Failed to read list of available data types in the clipboard with error: %w", error_from_status(metadata["status"]))
|
||||
}
|
||||
} else {
|
||||
switch metadata["status"] {
|
||||
case "DATA":
|
||||
current_mime := metadata["mime"]
|
||||
o := requested_mimes[current_mime]
|
||||
if o != nil {
|
||||
if getting_data_for != current_mime {
|
||||
if prev := requested_mimes[getting_data_for]; prev != nil && !prev.all_data_received {
|
||||
prev.all_data_received = true
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
prev.commit()
|
||||
wg.Done()
|
||||
}()
|
||||
|
||||
}
|
||||
getting_data_for = current_mime
|
||||
}
|
||||
if !o.all_data_received {
|
||||
o.add_data(payload)
|
||||
}
|
||||
}
|
||||
case "OK":
|
||||
case "DONE":
|
||||
if prev := requested_mimes[getting_data_for]; getting_data_for != "" && prev != nil && !prev.all_data_received {
|
||||
prev.all_data_received = true
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
prev.commit()
|
||||
wg.Done()
|
||||
}()
|
||||
getting_data_for = ""
|
||||
}
|
||||
lp.Quit(0)
|
||||
default:
|
||||
return fmt.Errorf("Failed to read data from the clipboard with error: %w", error_from_status(metadata["status"]))
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
esc_count := 0
|
||||
lp.OnKeyEvent = func(event *loop.KeyEvent) error {
|
||||
if event.MatchesPressOrRepeat("ctrl+c") || event.MatchesPressOrRepeat("esc") {
|
||||
event.Handled = true
|
||||
esc_count++
|
||||
if esc_count < 2 {
|
||||
key := "Esc"
|
||||
if event.MatchesPressOrRepeat("ctrl+c") {
|
||||
key = "Ctrl+C"
|
||||
}
|
||||
lp.QueueWriteString(fmt.Sprintf("Waiting for response from terminal, press %s again to abort. This could cause garbage to be spewed to the screen.\r\n", key))
|
||||
} else {
|
||||
return fmt.Errorf("Aborted by user!")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
err = lp.Run()
|
||||
wg.Wait()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
ds := lp.DeathSignalName()
|
||||
if ds != "" {
|
||||
fmt.Println("Killed by signal: ", ds)
|
||||
lp.KillIfSignalled()
|
||||
return
|
||||
}
|
||||
for _, o := range outputs {
|
||||
if o.err != nil {
|
||||
err = fmt.Errorf("Failed to get %s with error: %w", o.arg, o.err)
|
||||
return
|
||||
}
|
||||
if !o.started {
|
||||
err = fmt.Errorf("No data for %s with MIME type: %s", o.arg, o.mime_type)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
@@ -1,229 +0,0 @@
|
||||
// License: GPLv3 Copyright: 2022, Kovid Goyal, <kovid at kovidgoyal.net>
|
||||
|
||||
package clipboard
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"kitty/tools/tui/loop"
|
||||
"kitty/tools/utils"
|
||||
)
|
||||
|
||||
var _ = fmt.Print
|
||||
|
||||
type Input struct {
|
||||
src io.Reader
|
||||
arg string
|
||||
ext string
|
||||
is_stream bool
|
||||
mime_type string
|
||||
extra_mime_types []string
|
||||
}
|
||||
|
||||
func is_textual_mime(x string) bool {
|
||||
return strings.HasPrefix(x, "text/") || utils.KnownTextualMimes[x]
|
||||
}
|
||||
|
||||
func is_text_plain_mime(x string) bool {
|
||||
return x == "text/plain"
|
||||
}
|
||||
|
||||
func (self *Input) has_mime_matching(predicate func(string) bool) bool {
|
||||
if predicate(self.mime_type) {
|
||||
return true
|
||||
}
|
||||
for _, i := range self.extra_mime_types {
|
||||
if predicate(i) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func write_loop(inputs []*Input, opts *Options) (err error) {
|
||||
lp, err := loop.New(loop.NoAlternateScreen, loop.NoRestoreColors, loop.NoMouseTracking)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var waiting_for_write loop.IdType
|
||||
var buf [4096]byte
|
||||
aliases, aerr := parse_aliases(opts.Alias)
|
||||
if aerr != nil {
|
||||
return aerr
|
||||
}
|
||||
num_text_mimes := 0
|
||||
has_text_plain := false
|
||||
for _, i := range inputs {
|
||||
i.extra_mime_types = aliases[i.mime_type]
|
||||
if i.has_mime_matching(is_textual_mime) {
|
||||
num_text_mimes++
|
||||
if !has_text_plain && i.has_mime_matching(is_text_plain_mime) {
|
||||
has_text_plain = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if num_text_mimes > 0 && !has_text_plain {
|
||||
for _, i := range inputs {
|
||||
if i.has_mime_matching(is_textual_mime) {
|
||||
i.extra_mime_types = append(i.extra_mime_types, "text/plain")
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
make_metadata := func(ptype, mime string) map[string]string {
|
||||
ans := map[string]string{"type": ptype}
|
||||
if opts.UsePrimary {
|
||||
ans["loc"] = "primary"
|
||||
}
|
||||
if mime != "" {
|
||||
ans["mime"] = mime
|
||||
}
|
||||
return ans
|
||||
}
|
||||
|
||||
lp.OnInitialize = func() (string, error) {
|
||||
waiting_for_write = lp.QueueWriteString(encode(make_metadata("write", ""), ""))
|
||||
return "", nil
|
||||
}
|
||||
|
||||
write_chunk := func() error {
|
||||
if len(inputs) == 0 {
|
||||
return nil
|
||||
}
|
||||
i := inputs[0]
|
||||
n, err := i.src.Read(buf[:])
|
||||
if n > 0 {
|
||||
waiting_for_write = lp.QueueWriteString(encode_bytes(make_metadata("wdata", i.mime_type), buf[:n]))
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
if len(i.extra_mime_types) > 0 {
|
||||
lp.QueueWriteString(encode(make_metadata("walias", i.mime_type), strings.Join(i.extra_mime_types, " ")))
|
||||
}
|
||||
inputs = inputs[1:]
|
||||
if len(inputs) == 0 {
|
||||
lp.QueueWriteString(encode(make_metadata("wdata", ""), ""))
|
||||
waiting_for_write = 0
|
||||
}
|
||||
return lp.OnWriteComplete(waiting_for_write)
|
||||
}
|
||||
return fmt.Errorf("Failed to read from %s with error: %w", i.arg, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
lp.OnWriteComplete = func(msg_id loop.IdType) error {
|
||||
if waiting_for_write == msg_id {
|
||||
return write_chunk()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
lp.OnEscapeCode = func(etype loop.EscapeCodeType, data []byte) (err error) {
|
||||
metadata, _, err := parse_escape_code(etype, data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if metadata != nil && metadata["type"] == "write" {
|
||||
switch metadata["status"] {
|
||||
case "DONE":
|
||||
lp.Quit(0)
|
||||
case "EIO":
|
||||
return fmt.Errorf("Could not write to clipboard an I/O error occurred while the terminal was processing the data")
|
||||
case "EINVAL":
|
||||
return fmt.Errorf("Could not write to clipboard base64 encoding invalid")
|
||||
case "ENOSYS":
|
||||
return fmt.Errorf("Could not write to primary selection as the system does not support it")
|
||||
case "EPERM":
|
||||
return fmt.Errorf("Could not write to clipboard as permission was denied")
|
||||
case "EBUSY":
|
||||
return fmt.Errorf("Could not write to clipboard, a temporary error occurred, try again later.")
|
||||
default:
|
||||
return fmt.Errorf("Could not write to clipboard unknowns status returned from terminal: %#v", metadata["status"])
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
esc_count := 0
|
||||
lp.OnKeyEvent = func(event *loop.KeyEvent) error {
|
||||
if event.MatchesPressOrRepeat("ctrl+c") || event.MatchesPressOrRepeat("esc") {
|
||||
event.Handled = true
|
||||
esc_count++
|
||||
if esc_count < 2 {
|
||||
key := "Esc"
|
||||
if event.MatchesPressOrRepeat("ctrl+c") {
|
||||
key = "Ctrl+C"
|
||||
}
|
||||
lp.QueueWriteString(fmt.Sprintf("Waiting for response from terminal, press %s again to abort. This could cause garbage to be spewed to the screen.\r\n", key))
|
||||
} else {
|
||||
return fmt.Errorf("Aborted by user!")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
err = lp.Run()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
ds := lp.DeathSignalName()
|
||||
if ds != "" {
|
||||
fmt.Println("Killed by signal: ", ds)
|
||||
lp.KillIfSignalled()
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func run_set_loop(opts *Options, args []string) (err error) {
|
||||
inputs := make([]*Input, len(args))
|
||||
to_process := make([]*Input, len(args))
|
||||
defer func() {
|
||||
for _, i := range inputs {
|
||||
if i.src != nil {
|
||||
rc, ok := i.src.(io.Closer)
|
||||
if ok {
|
||||
rc.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
for i, arg := range args {
|
||||
if arg == "/dev/stdin" {
|
||||
f, _, err := preread_stdin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
inputs[i] = &Input{arg: arg, src: f, is_stream: true}
|
||||
} else {
|
||||
f, err := os.Open(arg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to open %s with error: %w", arg, err)
|
||||
}
|
||||
inputs[i] = &Input{arg: arg, src: f, ext: filepath.Ext(arg)}
|
||||
}
|
||||
if i < len(opts.Mime) {
|
||||
inputs[i].mime_type = opts.Mime[i]
|
||||
} else if inputs[i].is_stream {
|
||||
inputs[i].mime_type = "text/plain"
|
||||
} else if inputs[i].ext != "" {
|
||||
inputs[i].mime_type = utils.GuessMimeType(inputs[i].arg)
|
||||
}
|
||||
if inputs[i].mime_type == "" {
|
||||
return fmt.Errorf("Could not guess MIME type for %s use the --mime option to specify a MIME type", arg)
|
||||
}
|
||||
to_process[i] = inputs[i]
|
||||
if to_process[i].is_stream {
|
||||
}
|
||||
}
|
||||
return write_loop(to_process, opts)
|
||||
}
|
||||
@@ -1,386 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -1,264 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
// 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))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
// 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/tools/cli"
|
||||
"kitty/tools/cmd/ssh"
|
||||
"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)
|
||||
}
|
||||
@@ -1,376 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,652 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,629 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,327 +0,0 @@
|
||||
// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
|
||||
|
||||
package hints
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"kitty/tools/cli"
|
||||
"kitty/tools/tty"
|
||||
"kitty/tools/tui"
|
||||
"kitty/tools/tui/loop"
|
||||
"kitty/tools/utils"
|
||||
"kitty/tools/utils/style"
|
||||
"kitty/tools/wcswidth"
|
||||
)
|
||||
|
||||
var _ = fmt.Print
|
||||
|
||||
func convert_text(text string, cols int) string {
|
||||
lines := make([]string, 0, 64)
|
||||
empty_line := strings.Repeat("\x00", cols) + "\n"
|
||||
s1 := utils.NewLineScanner(text)
|
||||
for s1.Scan() {
|
||||
full_line := s1.Text()
|
||||
if full_line == "" {
|
||||
continue
|
||||
}
|
||||
if strings.TrimRight(full_line, "\r") == "" {
|
||||
for i := 0; i < len(full_line); i++ {
|
||||
lines = append(lines, empty_line)
|
||||
}
|
||||
continue
|
||||
}
|
||||
appended := false
|
||||
s2 := utils.NewSeparatorScanner(full_line, "\r")
|
||||
for s2.Scan() {
|
||||
line := s2.Text()
|
||||
if line != "" {
|
||||
line_sz := wcswidth.Stringwidth(line)
|
||||
extra := cols - line_sz
|
||||
if extra > 0 {
|
||||
line += strings.Repeat("\x00", extra)
|
||||
}
|
||||
lines = append(lines, line)
|
||||
lines = append(lines, "\r")
|
||||
appended = true
|
||||
}
|
||||
}
|
||||
if appended {
|
||||
lines[len(lines)-1] = "\n"
|
||||
}
|
||||
}
|
||||
ans := strings.Join(lines, "")
|
||||
return strings.TrimRight(ans, "\r\n")
|
||||
}
|
||||
|
||||
func parse_input(text string) string {
|
||||
cols, err := strconv.Atoi(os.Getenv("OVERLAID_WINDOW_COLS"))
|
||||
if err == nil {
|
||||
return convert_text(text, cols)
|
||||
}
|
||||
term, err := tty.OpenControllingTerm()
|
||||
if err == nil {
|
||||
sz, err := term.GetSize()
|
||||
term.Close()
|
||||
if err == nil {
|
||||
return convert_text(text, int(sz.Col))
|
||||
}
|
||||
}
|
||||
return convert_text(text, 80)
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
Match []string `json:"match"`
|
||||
Programs []string `json:"programs"`
|
||||
Multiple_joiner string `json:"multiple_joiner"`
|
||||
Customize_processing string `json:"customize_processing"`
|
||||
Type string `json:"type"`
|
||||
Groupdicts []map[string]any `json:"groupdicts"`
|
||||
Extra_cli_args []string `json:"extra_cli_args"`
|
||||
Linenum_action string `json:"linenum_action"`
|
||||
Cwd string `json:"cwd"`
|
||||
}
|
||||
|
||||
func encode_hint(num int, alphabet string) (res string) {
|
||||
runes := []rune(alphabet)
|
||||
d := len(runes)
|
||||
for res == "" || num > 0 {
|
||||
res = string(runes[num%d]) + res
|
||||
num /= d
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func decode_hint(x string, alphabet string) (ans int) {
|
||||
base := len(alphabet)
|
||||
index_map := make(map[rune]int, len(alphabet))
|
||||
for i, c := range alphabet {
|
||||
index_map[c] = i
|
||||
}
|
||||
for _, char := range x {
|
||||
ans = ans*base + index_map[char]
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func main(_ *cli.Command, o *Options, args []string) (rc int, err error) {
|
||||
output := tui.KittenOutputSerializer()
|
||||
if tty.IsTerminal(os.Stdin.Fd()) {
|
||||
tui.ReportError(fmt.Errorf("You must pass the text to be hinted on STDIN"))
|
||||
return 1, nil
|
||||
}
|
||||
stdin, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
tui.ReportError(fmt.Errorf("Failed to read from STDIN with error: %w", err))
|
||||
return 1, nil
|
||||
}
|
||||
if len(args) > 0 && o.CustomizeProcessing == "" && o.Type != "linenum" {
|
||||
tui.ReportError(fmt.Errorf("Extra command line arguments present: %s", strings.Join(args, " ")))
|
||||
return 1, nil
|
||||
}
|
||||
input_text := parse_input(utils.UnsafeBytesToString(stdin))
|
||||
text, all_marks, index_map, err := find_marks(input_text, o, os.Args[2:]...)
|
||||
if err != nil {
|
||||
tui.ReportError(err)
|
||||
return 1, nil
|
||||
}
|
||||
|
||||
result := Result{
|
||||
Programs: o.Program, Multiple_joiner: o.MultipleJoiner, Customize_processing: o.CustomizeProcessing, Type: o.Type,
|
||||
Extra_cli_args: args, Linenum_action: o.LinenumAction,
|
||||
}
|
||||
result.Cwd, _ = os.Getwd()
|
||||
alphabet := o.Alphabet
|
||||
if alphabet == "" {
|
||||
alphabet = DEFAULT_HINT_ALPHABET
|
||||
}
|
||||
ignore_mark_indices := utils.NewSet[int](8)
|
||||
window_title := o.WindowTitle
|
||||
if window_title == "" {
|
||||
switch o.Type {
|
||||
case "url":
|
||||
window_title = "Choose URL"
|
||||
default:
|
||||
window_title = "Choose text"
|
||||
}
|
||||
}
|
||||
current_text := ""
|
||||
current_input := ""
|
||||
match_suffix := ""
|
||||
switch o.AddTrailingSpace {
|
||||
case "always":
|
||||
match_suffix = " "
|
||||
case "never":
|
||||
default:
|
||||
if o.Multiple {
|
||||
match_suffix = " "
|
||||
}
|
||||
}
|
||||
chosen := []*Mark{}
|
||||
lp, err := loop.New(loop.NoAlternateScreen) // no alternate screen reduces flicker on exit
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
fctx := style.Context{AllowEscapeCodes: true}
|
||||
faint := fctx.SprintFunc("dim")
|
||||
hint_style := fctx.SprintFunc(fmt.Sprintf("fg=%s bg=%s bold", o.HintsForegroundColor, o.HintsBackgroundColor))
|
||||
text_style := fctx.SprintFunc(fmt.Sprintf("fg=bright-%s bold", o.HintsTextColor))
|
||||
|
||||
highlight_mark := func(m *Mark, mark_text string) string {
|
||||
hint := encode_hint(m.Index, alphabet)
|
||||
if current_input != "" && !strings.HasPrefix(hint, current_input) {
|
||||
return faint(mark_text)
|
||||
}
|
||||
hint = hint[len(current_input):]
|
||||
if hint == "" {
|
||||
hint = " "
|
||||
}
|
||||
mark_text = mark_text[len(hint):]
|
||||
return hint_style(hint) + text_style(mark_text)
|
||||
}
|
||||
|
||||
render := func() string {
|
||||
ans := text
|
||||
for i := len(all_marks) - 1; i >= 0; i-- {
|
||||
mark := &all_marks[i]
|
||||
if ignore_mark_indices.Has(mark.Index) {
|
||||
continue
|
||||
}
|
||||
mtext := highlight_mark(mark, ans[mark.Start:mark.End])
|
||||
ans = ans[:mark.Start] + mtext + ans[mark.End:]
|
||||
}
|
||||
ans = strings.ReplaceAll(ans, "\x00", "")
|
||||
return strings.TrimRightFunc(strings.NewReplacer("\r", "\r\n", "\n", "\r\n").Replace(ans), unicode.IsSpace)
|
||||
}
|
||||
|
||||
draw_screen := func() {
|
||||
lp.StartAtomicUpdate()
|
||||
defer lp.EndAtomicUpdate()
|
||||
if current_text == "" {
|
||||
current_text = render()
|
||||
}
|
||||
lp.ClearScreen()
|
||||
lp.QueueWriteString(current_text)
|
||||
}
|
||||
reset := func() {
|
||||
current_input = ""
|
||||
current_text = ""
|
||||
}
|
||||
|
||||
lp.OnInitialize = func() (string, error) {
|
||||
lp.SendOverlayReady()
|
||||
lp.SetCursorVisible(false)
|
||||
lp.SetWindowTitle(window_title)
|
||||
lp.AllowLineWrapping(false)
|
||||
draw_screen()
|
||||
return "", nil
|
||||
}
|
||||
lp.OnFinalize = func() string {
|
||||
lp.SetCursorVisible(true)
|
||||
return ""
|
||||
}
|
||||
lp.OnResize = func(old_size, new_size loop.ScreenSize) error {
|
||||
draw_screen()
|
||||
return nil
|
||||
}
|
||||
lp.OnText = func(text string, _, _ bool) error {
|
||||
changed := false
|
||||
for _, ch := range text {
|
||||
if strings.ContainsRune(alphabet, ch) {
|
||||
current_input += string(ch)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
matches := []*Mark{}
|
||||
for idx, m := range index_map {
|
||||
if eh := encode_hint(idx, alphabet); strings.HasPrefix(eh, current_input) {
|
||||
matches = append(matches, m)
|
||||
}
|
||||
}
|
||||
if len(matches) == 1 {
|
||||
chosen = append(chosen, matches[0])
|
||||
if o.Multiple {
|
||||
ignore_mark_indices.Add(matches[0].Index)
|
||||
reset()
|
||||
} else {
|
||||
lp.Quit(0)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
current_text = ""
|
||||
draw_screen()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
lp.OnKeyEvent = func(ev *loop.KeyEvent) error {
|
||||
if ev.MatchesPressOrRepeat("backspace") {
|
||||
ev.Handled = true
|
||||
r := []rune(current_input)
|
||||
if len(r) > 0 {
|
||||
r = r[:len(r)-1]
|
||||
current_input = string(r)
|
||||
current_text = ""
|
||||
}
|
||||
draw_screen()
|
||||
} else if ev.MatchesPressOrRepeat("enter") || ev.MatchesPressOrRepeat("space") {
|
||||
ev.Handled = true
|
||||
if current_input != "" {
|
||||
idx := decode_hint(current_input, alphabet)
|
||||
if m := index_map[idx]; m != nil {
|
||||
chosen = append(chosen, m)
|
||||
ignore_mark_indices.Add(idx)
|
||||
if o.Multiple {
|
||||
reset()
|
||||
draw_screen()
|
||||
} else {
|
||||
lp.Quit(0)
|
||||
}
|
||||
} else {
|
||||
current_input = ""
|
||||
current_text = ""
|
||||
draw_screen()
|
||||
}
|
||||
}
|
||||
} else if ev.MatchesPressOrRepeat("esc") {
|
||||
if o.Multiple {
|
||||
lp.Quit(0)
|
||||
} else {
|
||||
lp.Quit(1)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
if lp.ExitCode() != 0 {
|
||||
return lp.ExitCode(), nil
|
||||
}
|
||||
result.Match = make([]string, len(chosen))
|
||||
result.Groupdicts = make([]map[string]any, len(chosen))
|
||||
for i, m := range chosen {
|
||||
result.Match[i] = m.Text + match_suffix
|
||||
result.Groupdicts[i] = m.Groupdict
|
||||
}
|
||||
fmt.Println(output(result))
|
||||
return
|
||||
}
|
||||
|
||||
func EntryPoint(parent *cli.Command) {
|
||||
create_cmd(parent, main)
|
||||
}
|
||||
@@ -1,419 +0,0 @@
|
||||
// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
|
||||
|
||||
package hints
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"kitty"
|
||||
"kitty/tools/config"
|
||||
"kitty/tools/utils"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/seancfoley/ipaddress-go/ipaddr"
|
||||
"golang.org/x/exp/slices"
|
||||
)
|
||||
|
||||
var _ = fmt.Print
|
||||
|
||||
const (
|
||||
DEFAULT_HINT_ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyz"
|
||||
FILE_EXTENSION = `\.(?:[a-zA-Z0-9]{2,7}|[ahcmo])(?:\b|[^.])`
|
||||
)
|
||||
|
||||
func path_regex() string {
|
||||
return fmt.Sprintf(`(?:\S*?/[\r\S]+)|(?:\S[\r\S]*%s)\b`, FILE_EXTENSION)
|
||||
}
|
||||
|
||||
func default_linenum_regex() string {
|
||||
return fmt.Sprintf(`(?P<path>%s):(?P<line>\d+)`, path_regex())
|
||||
}
|
||||
|
||||
type Mark struct {
|
||||
Index int `json:"index"`
|
||||
Start int `json:"start"`
|
||||
End int `json:"end"`
|
||||
Text string `json:"text"`
|
||||
Group_id string `json:"group_id"`
|
||||
Is_hyperlink bool `json:"is_hyperlink"`
|
||||
Groupdict map[string]any `json:"groupdict"`
|
||||
}
|
||||
|
||||
func process_escape_codes(text string) (ans string, hyperlinks []Mark) {
|
||||
removed_size, idx := 0, 0
|
||||
active_hyperlink_url := ""
|
||||
active_hyperlink_id := ""
|
||||
active_hyperlink_start_offset := 0
|
||||
|
||||
add_hyperlink := func(end int) {
|
||||
hyperlinks = append(hyperlinks, Mark{
|
||||
Index: idx, Start: active_hyperlink_start_offset, End: end, Text: active_hyperlink_url, Is_hyperlink: true, Group_id: active_hyperlink_id})
|
||||
active_hyperlink_url, active_hyperlink_id = "", ""
|
||||
active_hyperlink_start_offset = 0
|
||||
idx++
|
||||
}
|
||||
|
||||
ans = utils.ReplaceAll(utils.MustCompile("\x1b(?:\\[[0-9;:]*?m|\\].*?\x1b\\\\)"), text, func(raw string, groupdict map[string]utils.SubMatch) string {
|
||||
if !strings.HasPrefix(raw, "\x1b]8") {
|
||||
removed_size += len(raw)
|
||||
return ""
|
||||
}
|
||||
start := groupdict[""].Start - removed_size
|
||||
removed_size += len(raw)
|
||||
if active_hyperlink_url != "" {
|
||||
add_hyperlink(start)
|
||||
}
|
||||
raw = raw[4 : len(raw)-2]
|
||||
if metadata, url, found := strings.Cut(raw, ";"); found && url != "" {
|
||||
active_hyperlink_url = url
|
||||
active_hyperlink_start_offset = start
|
||||
if metadata != "" {
|
||||
for _, entry := range strings.Split(metadata, ":") {
|
||||
if strings.HasPrefix(entry, "id=") && len(entry) > 3 {
|
||||
active_hyperlink_id = entry[3:]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
})
|
||||
if active_hyperlink_url != "" {
|
||||
add_hyperlink(len(ans))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
type PostProcessorFunc = func(string, int, int) (int, int)
|
||||
type GroupProcessorFunc = func(map[string]string)
|
||||
|
||||
func is_punctuation(b string) bool {
|
||||
switch b {
|
||||
case ",", ".", "?", "!":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func closing_bracket_for(ch string) string {
|
||||
switch ch {
|
||||
case "(":
|
||||
return ")"
|
||||
case "[":
|
||||
return "]"
|
||||
case "{":
|
||||
return "}"
|
||||
case "<":
|
||||
return ">"
|
||||
case "*":
|
||||
return "*"
|
||||
case `"`:
|
||||
return `"`
|
||||
case "'":
|
||||
return "'"
|
||||
case "“":
|
||||
return "”"
|
||||
case "‘":
|
||||
return "’"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func char_at(s string, i int) string {
|
||||
ans, _ := utf8.DecodeRuneInString(s[i:])
|
||||
if ans == utf8.RuneError {
|
||||
return ""
|
||||
}
|
||||
return string(ans)
|
||||
}
|
||||
|
||||
func matching_remover(openers ...string) PostProcessorFunc {
|
||||
return func(text string, s, e int) (int, int) {
|
||||
if s < e && e <= len(text) {
|
||||
before := char_at(text, s)
|
||||
if slices.Index(openers, before) > -1 {
|
||||
q := closing_bracket_for(before)
|
||||
if e > 0 && char_at(text, e-1) == q {
|
||||
s++
|
||||
e--
|
||||
} else if char_at(text, e) == q {
|
||||
s++
|
||||
}
|
||||
}
|
||||
}
|
||||
return s, e
|
||||
}
|
||||
}
|
||||
|
||||
func linenum_group_processor(gd map[string]string) {
|
||||
pat := utils.MustCompile(`:\d+$`)
|
||||
gd[`path`] = pat.ReplaceAllStringFunc(gd["path"], func(m string) string {
|
||||
gd["line"] = m[1:]
|
||||
return ``
|
||||
})
|
||||
gd[`path`] = utils.Expanduser(gd[`path`])
|
||||
}
|
||||
|
||||
var PostProcessorMap = (&utils.Once[map[string]PostProcessorFunc]{Run: func() map[string]PostProcessorFunc {
|
||||
return map[string]PostProcessorFunc{
|
||||
"url": func(text string, s, e int) (int, int) {
|
||||
if s > 4 && text[s-5:s] == "link:" { // asciidoc URLs
|
||||
url := text[s:e]
|
||||
idx := strings.LastIndex(url, "[")
|
||||
if idx > -1 {
|
||||
e -= len(url) - idx
|
||||
}
|
||||
}
|
||||
for e > 1 && is_punctuation(char_at(text, e)) { // remove trailing punctuation
|
||||
e--
|
||||
}
|
||||
// truncate url at closing bracket/quote
|
||||
if s > 0 && e <= len(text) && closing_bracket_for(char_at(text, s-1)) != "" {
|
||||
q := closing_bracket_for(char_at(text, s-1))
|
||||
idx := strings.Index(text[s:], q)
|
||||
if idx > 0 {
|
||||
e = s + idx
|
||||
}
|
||||
}
|
||||
// reStructuredText URLs
|
||||
if e > 3 && text[e-2:e] == "`_" {
|
||||
e -= 2
|
||||
}
|
||||
return s, e
|
||||
},
|
||||
|
||||
"brackets": matching_remover("(", "{", "[", "<"),
|
||||
"quotes": matching_remover("'", `"`, "“", "‘"),
|
||||
"ip": func(text string, s, e int) (int, int) {
|
||||
addr := ipaddr.NewHostName(text[s:e])
|
||||
if !addr.IsAddress() {
|
||||
return -1, -1
|
||||
}
|
||||
return s, e
|
||||
},
|
||||
}
|
||||
}}).Get
|
||||
|
||||
type KittyOpts struct {
|
||||
Url_prefixes *utils.Set[string]
|
||||
Select_by_word_characters string
|
||||
}
|
||||
|
||||
func read_relevant_kitty_opts(path string) KittyOpts {
|
||||
ans := KittyOpts{Select_by_word_characters: kitty.KittyConfigDefaults.Select_by_word_characters}
|
||||
handle_line := func(key, val string) error {
|
||||
switch key {
|
||||
case "url_prefixes":
|
||||
ans.Url_prefixes = utils.NewSetWithItems(strings.Split(val, " ")...)
|
||||
case "select_by_word_characters":
|
||||
ans.Select_by_word_characters = strings.TrimSpace(val)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
cp := config.ConfigParser{LineHandler: handle_line}
|
||||
cp.ParseFiles(path)
|
||||
if ans.Url_prefixes == nil {
|
||||
ans.Url_prefixes = utils.NewSetWithItems(kitty.KittyConfigDefaults.Url_prefixes...)
|
||||
}
|
||||
return ans
|
||||
}
|
||||
|
||||
var RelevantKittyOpts = (&utils.Once[KittyOpts]{Run: func() KittyOpts {
|
||||
return read_relevant_kitty_opts(filepath.Join(utils.ConfigDir(), "kitty.conf"))
|
||||
}}).Get
|
||||
|
||||
func functions_for(opts *Options) (pattern string, post_processors []PostProcessorFunc, group_processors []GroupProcessorFunc) {
|
||||
switch opts.Type {
|
||||
case "url":
|
||||
var url_prefixes *utils.Set[string]
|
||||
if opts.UrlPrefixes == "default" {
|
||||
url_prefixes = RelevantKittyOpts().Url_prefixes
|
||||
} else {
|
||||
url_prefixes = utils.NewSetWithItems(strings.Split(opts.UrlPrefixes, ",")...)
|
||||
}
|
||||
pattern = fmt.Sprintf(`(?:%s)://[^%s]{3,}`, strings.Join(url_prefixes.AsSlice(), "|"), URL_DELIMITERS)
|
||||
post_processors = append(post_processors, PostProcessorMap()["url"])
|
||||
case "path":
|
||||
pattern = path_regex()
|
||||
post_processors = append(post_processors, PostProcessorMap()["brackets"], PostProcessorMap()["quotes"])
|
||||
case "line":
|
||||
pattern = "(?m)^\\s*(.+)[\\s\x00]*$"
|
||||
case "hash":
|
||||
pattern = "[0-9a-f][0-9a-f\r]{6,127}"
|
||||
case "ip":
|
||||
pattern = (
|
||||
// IPv4 with no validation
|
||||
`((?:\d{1,3}\.){3}\d{1,3}` + "|" +
|
||||
// IPv6 with no validation
|
||||
`(?:[a-fA-F0-9]{0,4}:){2,7}[a-fA-F0-9]{1,4})`)
|
||||
post_processors = append(post_processors, PostProcessorMap()["ip"])
|
||||
case "word":
|
||||
chars := opts.WordCharacters
|
||||
if chars == "" {
|
||||
chars = RelevantKittyOpts().Select_by_word_characters
|
||||
}
|
||||
chars = regexp.QuoteMeta(chars)
|
||||
chars = strings.ReplaceAll(chars, "-", "\\-")
|
||||
pattern = fmt.Sprintf(`[%s\pL\pN]{%d,}`, chars, opts.MinimumMatchLength)
|
||||
post_processors = append(post_processors, PostProcessorMap()["brackets"], PostProcessorMap()["quotes"])
|
||||
default:
|
||||
pattern = opts.Regex
|
||||
if opts.Type == "linenum" {
|
||||
if pattern == kitty.HintsDefaultRegex {
|
||||
pattern = default_linenum_regex()
|
||||
}
|
||||
post_processors = append(post_processors, PostProcessorMap()["brackets"], PostProcessorMap()["quotes"])
|
||||
group_processors = append(group_processors, linenum_group_processor)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func mark(r *regexp.Regexp, post_processors []PostProcessorFunc, group_processors []GroupProcessorFunc, text string, opts *Options) (ans []Mark) {
|
||||
sanitize_pat := regexp.MustCompile("[\r\n\x00]")
|
||||
names := r.SubexpNames()
|
||||
for i, v := range r.FindAllStringSubmatchIndex(text, -1) {
|
||||
match_start, match_end := v[0], v[1]
|
||||
for match_end > match_start+1 && text[match_end-1] == 0 {
|
||||
match_end--
|
||||
}
|
||||
full_match := text[match_start:match_end]
|
||||
if len([]rune(full_match)) < opts.MinimumMatchLength {
|
||||
continue
|
||||
}
|
||||
for _, f := range post_processors {
|
||||
match_start, match_end = f(text, match_start, match_end)
|
||||
if match_start < 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if match_start < 0 {
|
||||
continue
|
||||
}
|
||||
full_match = sanitize_pat.ReplaceAllLiteralString(text[match_start:match_end], "")
|
||||
gd := make(map[string]string, len(names))
|
||||
for x, name := range names {
|
||||
if name != "" {
|
||||
idx := 2 * x
|
||||
if s, e := v[idx], v[idx+1]; s > -1 && e > -1 {
|
||||
s = utils.Max(s, match_start)
|
||||
e = utils.Min(e, match_end)
|
||||
gd[name] = sanitize_pat.ReplaceAllLiteralString(text[s:e], "")
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, f := range group_processors {
|
||||
f(gd)
|
||||
}
|
||||
gd2 := make(map[string]any, len(gd))
|
||||
for k, v := range gd {
|
||||
gd2[k] = v
|
||||
}
|
||||
ans = append(ans, Mark{
|
||||
Index: i, Start: match_start, End: match_end, Text: full_match, Groupdict: gd2,
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
type ErrNoMatches struct{ Type string }
|
||||
|
||||
func adjust_python_offsets(text string, marks []Mark) error {
|
||||
// python returns rune based offsets (unicode chars not utf-8 bytes)
|
||||
adjust := utils.RuneOffsetsToByteOffsets(text)
|
||||
for i := range marks {
|
||||
mark := &marks[i]
|
||||
if mark.End < mark.Start {
|
||||
return fmt.Errorf("The end of a mark must not be before its start")
|
||||
}
|
||||
s, e := adjust(mark.Start), adjust(mark.End)
|
||||
if s < 0 || e < 0 {
|
||||
return fmt.Errorf("Overlapping marks are not supported")
|
||||
}
|
||||
mark.Start, mark.End = s, e
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *ErrNoMatches) Error() string {
|
||||
none_of := "matches"
|
||||
switch self.Type {
|
||||
case "urls":
|
||||
none_of = "URLs"
|
||||
case "hyperlinks":
|
||||
none_of = "hyperlinks"
|
||||
}
|
||||
return fmt.Sprintf("No %s found", none_of)
|
||||
}
|
||||
|
||||
func find_marks(text string, opts *Options, cli_args ...string) (sanitized_text string, ans []Mark, index_map map[int]*Mark, err error) {
|
||||
sanitized_text, hyperlinks := process_escape_codes(text)
|
||||
|
||||
run_basic_matching := func() error {
|
||||
pattern, post_processors, group_processors := functions_for(opts)
|
||||
r, err := regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to compile the regex pattern: %#v with error: %w", pattern, err)
|
||||
}
|
||||
ans = mark(r, post_processors, group_processors, sanitized_text, opts)
|
||||
return nil
|
||||
}
|
||||
|
||||
if opts.CustomizeProcessing != "" {
|
||||
cmd := exec.Command(utils.KittyExe(), append([]string{"+runpy", "from kittens.hints.main import custom_marking; custom_marking()"}, cli_args...)...)
|
||||
cmd.Stdin = strings.NewReader(sanitized_text)
|
||||
stdout, stderr := bytes.Buffer{}, bytes.Buffer{}
|
||||
cmd.Stdout, cmd.Stderr = &stdout, &stderr
|
||||
err = cmd.Run()
|
||||
if err != nil {
|
||||
var e *exec.ExitError
|
||||
if errors.As(err, &e) && e.ExitCode() == 2 {
|
||||
err = run_basic_matching()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
goto process_answer
|
||||
} else {
|
||||
return "", nil, nil, fmt.Errorf("Failed to run custom processor %#v with error: %w\n%s", opts.CustomizeProcessing, err, stderr.String())
|
||||
}
|
||||
}
|
||||
ans = make([]Mark, 0, 32)
|
||||
err = json.Unmarshal(stdout.Bytes(), &ans)
|
||||
if err != nil {
|
||||
return "", nil, nil, fmt.Errorf("Failed to load output from custom processor %#v with error: %w", opts.CustomizeProcessing, err)
|
||||
}
|
||||
err = adjust_python_offsets(sanitized_text, ans)
|
||||
if err != nil {
|
||||
return "", nil, nil, fmt.Errorf("Custom processor %#v produced invalid mark output with error: %w", opts.CustomizeProcessing, err)
|
||||
}
|
||||
} else if opts.Type == "hyperlink" {
|
||||
ans = hyperlinks
|
||||
} else {
|
||||
err = run_basic_matching()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
process_answer:
|
||||
if len(ans) == 0 {
|
||||
return "", nil, nil, &ErrNoMatches{Type: opts.Type}
|
||||
}
|
||||
largest_index := ans[len(ans)-1].Index
|
||||
offset := utils.Max(0, opts.HintsOffset)
|
||||
index_map = make(map[int]*Mark, len(ans))
|
||||
for i := range ans {
|
||||
m := &ans[i]
|
||||
if opts.Ascending {
|
||||
m.Index += offset
|
||||
} else {
|
||||
m.Index = largest_index - m.Index + offset
|
||||
}
|
||||
index_map[m.Index] = m
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
|
||||
|
||||
package hints
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"kitty"
|
||||
"kitty/tools/utils"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
)
|
||||
|
||||
var _ = fmt.Print
|
||||
|
||||
func TestHintMarking(t *testing.T) {
|
||||
|
||||
var opts *Options
|
||||
cols := 20
|
||||
cli_args := []string{}
|
||||
|
||||
reset := func() {
|
||||
opts = &Options{Type: "url", UrlPrefixes: "default", Regex: kitty.HintsDefaultRegex}
|
||||
cols = 20
|
||||
cli_args = []string{}
|
||||
}
|
||||
|
||||
r := func(text string, url ...string) (marks []Mark) {
|
||||
ptext := convert_text(text, cols)
|
||||
ptext, marks, _, err := find_marks(ptext, opts, cli_args...)
|
||||
if err != nil {
|
||||
var e *ErrNoMatches
|
||||
if len(url) != 0 || !errors.As(err, &e) {
|
||||
t.Fatalf("%#v failed with error: %s", text, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
actual := utils.Map(func(m Mark) string { return m.Text }, marks)
|
||||
if diff := cmp.Diff(url, actual); diff != "" {
|
||||
t.Fatalf("%#v failed:\n%s", text, diff)
|
||||
}
|
||||
for _, m := range marks {
|
||||
q := strings.NewReplacer("\n", "", "\r", "", "\x00", "").Replace(ptext[m.Start:m.End])
|
||||
if diff := cmp.Diff(m.Text, q); diff != "" {
|
||||
t.Fatalf("Mark start and end dont point to correct offset in text for %#v\n%s", text, diff)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
reset()
|
||||
u := `http://test.me/`
|
||||
r(u, u)
|
||||
r(`"`+u+`"`, u)
|
||||
r("("+u+")", u)
|
||||
cols = len(u)
|
||||
r(u+"\nxxx", u+"xxx")
|
||||
cols = 20
|
||||
r("link:"+u+"[xxx]", u)
|
||||
r("`xyz <"+u+">`_.", u)
|
||||
r(`<a href="`+u+`">moo`, u)
|
||||
r("\x1b[mhttp://test.me/1234\n\x1b[mx", "http://test.me/1234")
|
||||
r("\x1b[mhttp://test.me/12345\r\x1b[m6\n\x1b[mx", "http://test.me/123456")
|
||||
|
||||
opts.Type = "linenum"
|
||||
m := func(text, path string, line int) {
|
||||
ptext := convert_text(text, cols)
|
||||
_, marks, _, err := find_marks(ptext, opts, cli_args...)
|
||||
if err != nil {
|
||||
t.Fatalf("%#v failed with error: %s", text, err)
|
||||
}
|
||||
gd := map[string]any{"path": path, "line": strconv.Itoa(line)}
|
||||
if diff := cmp.Diff(marks[0].Groupdict, gd); diff != "" {
|
||||
t.Fatalf("%#v failed:\n%s", text, diff)
|
||||
}
|
||||
}
|
||||
m("file.c:23", "file.c", 23)
|
||||
m("file.c:23:32", "file.c", 23)
|
||||
m("file.cpp:23:1", "file.cpp", 23)
|
||||
m("a/file.c:23", "a/file.c", 23)
|
||||
m("a/file.c:23:32", "a/file.c", 23)
|
||||
m("~/file.c:23:32", utils.Expanduser("~/file.c"), 23)
|
||||
|
||||
reset()
|
||||
opts.Type = "path"
|
||||
r("file.c", "file.c")
|
||||
r("file.c.", "file.c")
|
||||
r("file.epub.", "file.epub")
|
||||
r("(file.epub)", "file.epub")
|
||||
r("some/path", "some/path")
|
||||
|
||||
reset()
|
||||
cols = 60
|
||||
opts.Type = "ip"
|
||||
r(`100.64.0.0`, `100.64.0.0`)
|
||||
r(`2001:0db8:0000:0000:0000:ff00:0042:8329`, `2001:0db8:0000:0000:0000:ff00:0042:8329`)
|
||||
r(`2001:db8:0:0:0:ff00:42:8329`, `2001:db8:0:0:0:ff00:42:8329`)
|
||||
r(`2001:db8::ff00:42:8329`, `2001:db8::ff00:42:8329`)
|
||||
r(`2001:DB8::FF00:42:8329`, `2001:DB8::FF00:42:8329`)
|
||||
r(`0000:0000:0000:0000:0000:0000:0000:0001`, `0000:0000:0000:0000:0000:0000:0000:0001`)
|
||||
r(`::1`, `::1`)
|
||||
r(`255.255.255.256`)
|
||||
r(`:1`)
|
||||
|
||||
reset()
|
||||
tdir := t.TempDir()
|
||||
simple := filepath.Join(tdir, "simple.py")
|
||||
cli_args = []string{"--customize-processing", simple, "extra1"}
|
||||
os.WriteFile(simple, []byte(`
|
||||
def mark(text, args, Mark, extra_cli_args, *a):
|
||||
import re
|
||||
for idx, m in enumerate(re.finditer(r'\w+', text)):
|
||||
start, end = m.span()
|
||||
mark_text = text[start:end].replace('\n', '').replace('\0', '')
|
||||
yield Mark(idx, start, end, mark_text, {"idx": idx, "args": extra_cli_args})
|
||||
`), 0o600)
|
||||
opts.Type = "regex"
|
||||
opts.CustomizeProcessing = simple
|
||||
marks := r("漢字 b", `漢字`, `b`)
|
||||
if diff := cmp.Diff(marks[0].Groupdict, map[string]any{"idx": float64(0), "args": []any{"extra1"}}); diff != "" {
|
||||
t.Fatalf("Did not get expected groupdict from custom processor:\n%s", diff)
|
||||
}
|
||||
opts.Regex = "b"
|
||||
os.WriteFile(simple, []byte(""), 0o600)
|
||||
r("a b", `b`)
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
// generated by gen-wcwidth.py, do not edit
|
||||
|
||||
package hints
|
||||
|
||||
const URL_DELIMITERS = `\x00-\x09\x0b-\x0c\x0e-\x20\x7f-\xa0\xad\x{600}-\x{605}\x{61c}\x{6dd}\x{70f}\x{890}-\x{891}\x{8e2}\x{1680}\x{180e}\x{2000}-\x{200f}\x{2028}-\x{202f}\x{205f}-\x{2064}\x{2066}-\x{206f}\x{3000}\x{d800}-\x{f8ff}\x{feff}\x{fff9}-\x{fffb}\x{110bd}\x{110cd}\x{13430}-\x{1343f}\x{1bca0}-\x{1bca3}\x{1d173}-\x{1d17a}\x{e0001}\x{e0020}-\x{e007f}\x{f0000}-\x{ffffd}\x{100000}-\x{10fffd}`
|
||||
@@ -1,422 +0,0 @@
|
||||
// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
|
||||
|
||||
package hyperlinked_grep
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"kitty/tools/cli"
|
||||
"kitty/tools/utils"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
var _ = fmt.Print
|
||||
|
||||
var RgExe = (&utils.Once[string]{Run: func() string {
|
||||
return utils.FindExe("rg")
|
||||
}}).Get
|
||||
|
||||
func get_options_for_rg() (expecting_args map[string]bool, alias_map map[string]string, err error) {
|
||||
var raw []byte
|
||||
raw, err = exec.Command(RgExe(), "--help").Output()
|
||||
if err != nil {
|
||||
err = fmt.Errorf("Failed to execute rg: %w", err)
|
||||
return
|
||||
}
|
||||
scanner := utils.NewLineScanner(utils.UnsafeBytesToString(raw))
|
||||
options_started := false
|
||||
expecting_args = make(map[string]bool, 64)
|
||||
alias_map = make(map[string]string, 52)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if options_started {
|
||||
s := strings.TrimLeft(line, " ")
|
||||
indent := len(line) - len(s)
|
||||
if indent < 12 && indent > 0 {
|
||||
s, _, expecting_arg := strings.Cut(s, "<")
|
||||
single_letter_aliases := make([]string, 0, 1)
|
||||
long_option_names := make([]string, 0, 1)
|
||||
for _, x := range strings.Split(s, ",") {
|
||||
x = strings.TrimSpace(x)
|
||||
if strings.HasPrefix(x, "--") {
|
||||
long_option_names = append(long_option_names, x[2:])
|
||||
} else if strings.HasPrefix(x, "-") {
|
||||
single_letter_aliases = append(single_letter_aliases, x[1:])
|
||||
}
|
||||
}
|
||||
if len(long_option_names) == 0 {
|
||||
err = fmt.Errorf("Failed to parse rg help output line: %s", line)
|
||||
return
|
||||
}
|
||||
for _, x := range single_letter_aliases {
|
||||
alias_map[x] = long_option_names[0]
|
||||
}
|
||||
for _, x := range long_option_names[1:] {
|
||||
alias_map[x] = long_option_names[0]
|
||||
}
|
||||
expecting_args[long_option_names[0]] = expecting_arg
|
||||
}
|
||||
} else {
|
||||
if strings.HasPrefix(line, "OPTIONS:") {
|
||||
options_started = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
type kitten_options struct {
|
||||
matching_lines, context_lines, file_headers bool
|
||||
with_filename, heading, line_number bool
|
||||
stats, count, count_matches bool
|
||||
files, files_with_matches, files_without_match bool
|
||||
vimgrep bool
|
||||
}
|
||||
|
||||
func default_kitten_opts() *kitten_options {
|
||||
return &kitten_options{
|
||||
matching_lines: true, context_lines: true, file_headers: true,
|
||||
with_filename: true, heading: true, line_number: true,
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func parse_args(args ...string) (delegate_to_rg bool, sanitized_args []string, kitten_opts *kitten_options, err error) {
|
||||
options_that_expect_args, alias_map, err := get_options_for_rg()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
options_that_expect_args["kitten"] = true
|
||||
kitten_opts = default_kitten_opts()
|
||||
sanitized_args = make([]string, 0, len(args))
|
||||
expecting_option_arg := ""
|
||||
|
||||
context_separator := "--"
|
||||
field_context_separator := "-"
|
||||
field_match_separator := "-"
|
||||
|
||||
handle_option_arg := func(key, val string, with_equals bool) error {
|
||||
if key != "kitten" {
|
||||
if with_equals {
|
||||
sanitized_args = append(sanitized_args, "--"+key+"="+val)
|
||||
} else {
|
||||
sanitized_args = append(sanitized_args, "--"+key, val)
|
||||
}
|
||||
}
|
||||
switch key {
|
||||
case "path-separator":
|
||||
if val != string(os.PathSeparator) {
|
||||
delegate_to_rg = true
|
||||
}
|
||||
case "context-separator":
|
||||
context_separator = val
|
||||
case "field-context-separator":
|
||||
field_context_separator = val
|
||||
case "field-match-separator":
|
||||
field_match_separator = val
|
||||
case "kitten":
|
||||
k, v, found := strings.Cut(val, "=")
|
||||
if !found || k != "hyperlink" {
|
||||
return fmt.Errorf("Unknown --kitten option: %s", val)
|
||||
}
|
||||
for _, x := range strings.Split(v, ",") {
|
||||
switch x {
|
||||
case "none":
|
||||
kitten_opts.context_lines = false
|
||||
kitten_opts.file_headers = false
|
||||
kitten_opts.matching_lines = false
|
||||
case "all":
|
||||
kitten_opts.context_lines = true
|
||||
kitten_opts.file_headers = true
|
||||
kitten_opts.matching_lines = true
|
||||
case "matching_lines":
|
||||
kitten_opts.matching_lines = true
|
||||
case "file_headers":
|
||||
kitten_opts.file_headers = true
|
||||
case "context_lines":
|
||||
kitten_opts.context_lines = true
|
||||
default:
|
||||
return fmt.Errorf("hyperlink option invalid: %s", x)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
handle_bool_option := func(key string) {
|
||||
switch key {
|
||||
case "no-context-separator":
|
||||
context_separator = ""
|
||||
case "no-filename":
|
||||
kitten_opts.with_filename = false
|
||||
case "with-filename":
|
||||
kitten_opts.with_filename = true
|
||||
case "heading":
|
||||
kitten_opts.heading = true
|
||||
case "no-heading":
|
||||
kitten_opts.heading = false
|
||||
case "line-number":
|
||||
kitten_opts.line_number = true
|
||||
case "no-line-number":
|
||||
kitten_opts.line_number = false
|
||||
case "pretty":
|
||||
kitten_opts.line_number = true
|
||||
kitten_opts.heading = true
|
||||
case "stats":
|
||||
kitten_opts.stats = true
|
||||
case "count":
|
||||
kitten_opts.count = true
|
||||
case "count-matches":
|
||||
kitten_opts.count_matches = true
|
||||
case "files":
|
||||
kitten_opts.files = true
|
||||
case "files-with-matches":
|
||||
kitten_opts.files_with_matches = true
|
||||
case "files-without-match":
|
||||
kitten_opts.files_without_match = true
|
||||
case "vimgrep":
|
||||
kitten_opts.vimgrep = true
|
||||
case "null", "null-data", "type-list", "version", "help":
|
||||
delegate_to_rg = true
|
||||
}
|
||||
}
|
||||
|
||||
for i, x := range args {
|
||||
if expecting_option_arg != "" {
|
||||
if err = handle_option_arg(expecting_option_arg, x, false); err != nil {
|
||||
return
|
||||
}
|
||||
expecting_option_arg = ""
|
||||
} else {
|
||||
if x == "--" {
|
||||
sanitized_args = append(sanitized_args, args[i:]...)
|
||||
break
|
||||
}
|
||||
if strings.HasPrefix(x, "--") {
|
||||
a, b, found := strings.Cut(x, "=")
|
||||
a = a[2:]
|
||||
q := alias_map[a]
|
||||
if q != "" {
|
||||
a = q
|
||||
}
|
||||
if found {
|
||||
if _, is_known_option := options_that_expect_args[a]; is_known_option {
|
||||
if err = handle_option_arg(a, b, true); err != nil {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
sanitized_args = append(sanitized_args, x)
|
||||
}
|
||||
} else {
|
||||
if options_that_expect_args[a] {
|
||||
expecting_option_arg = a
|
||||
} else {
|
||||
handle_bool_option(a)
|
||||
sanitized_args = append(sanitized_args, x)
|
||||
}
|
||||
}
|
||||
} else if strings.HasPrefix(x, "-") {
|
||||
ok := true
|
||||
chars := make([]string, len(x)-1)
|
||||
for i, ch := range x[1:] {
|
||||
chars[i] = string(ch)
|
||||
_, ok = alias_map[string(ch)]
|
||||
if !ok {
|
||||
sanitized_args = append(sanitized_args, x)
|
||||
break
|
||||
}
|
||||
}
|
||||
if ok {
|
||||
for _, ch := range chars {
|
||||
target := alias_map[ch]
|
||||
if options_that_expect_args[target] {
|
||||
expecting_option_arg = target
|
||||
} else {
|
||||
handle_bool_option(target)
|
||||
sanitized_args = append(sanitized_args, "-"+ch)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
sanitized_args = append(sanitized_args, x)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !kitten_opts.with_filename || context_separator != "--" || field_context_separator != "-" || field_match_separator != "-" {
|
||||
delegate_to_rg = true
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
type stdout_filter struct {
|
||||
prefix []byte
|
||||
process_line func(string)
|
||||
}
|
||||
|
||||
func (self *stdout_filter) Write(p []byte) (n int, err error) {
|
||||
n = len(p)
|
||||
for len(p) > 0 {
|
||||
idx := bytes.IndexByte(p, '\n')
|
||||
if idx < 0 {
|
||||
self.prefix = append(self.prefix, p...)
|
||||
break
|
||||
}
|
||||
line := p[:idx]
|
||||
if len(self.prefix) > 0 {
|
||||
self.prefix = append(self.prefix, line...)
|
||||
line = self.prefix
|
||||
}
|
||||
p = p[idx+1:]
|
||||
self.process_line(utils.UnsafeBytesToString(line))
|
||||
self.prefix = self.prefix[:0]
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func main(_ *cli.Command, _ *Options, args []string) (rc int, err error) {
|
||||
delegate_to_rg, sanitized_args, kitten_opts, err := parse_args(args...)
|
||||
if delegate_to_rg {
|
||||
sanitized_args = append([]string{"rg"}, sanitized_args...)
|
||||
err = unix.Exec(RgExe(), sanitized_args, os.Environ())
|
||||
if err != nil {
|
||||
err = fmt.Errorf("Failed to execute rg: %w", err)
|
||||
rc = 1
|
||||
}
|
||||
return
|
||||
}
|
||||
cmdline := append([]string{"--pretty", "--with-filename"}, sanitized_args...)
|
||||
cmd := exec.Command(RgExe(), cmdline...)
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stderr = os.Stderr
|
||||
buf := stdout_filter{prefix: make([]byte, 0, 8*1024)}
|
||||
cmd.Stdout = &buf
|
||||
sgr_pat := regexp.MustCompile("\x1b\\[.*?m")
|
||||
osc_pat := regexp.MustCompile("\x1b\\].*?\x1b\\\\")
|
||||
num_pat := regexp.MustCompile(`^(\d+)([:-])`)
|
||||
path_with_count_pat := regexp.MustCompile(`^(.*?)(:\d+)`)
|
||||
path_with_linenum_pat := regexp.MustCompile(`^(.*?):(\d+):`)
|
||||
stats_pat := regexp.MustCompile(`^\d+ matches$`)
|
||||
vimgrep_pat := regexp.MustCompile(`^(.*?):(\d+):(\d+):`)
|
||||
|
||||
in_stats := false
|
||||
in_result := ""
|
||||
hostname := utils.Hostname()
|
||||
|
||||
get_quoted_url := func(file_path string) string {
|
||||
q, err := filepath.Abs(file_path)
|
||||
if err == nil {
|
||||
file_path = q
|
||||
}
|
||||
file_path = filepath.ToSlash(file_path)
|
||||
file_path = strings.Join(utils.Map(url.PathEscape, strings.Split(file_path, "/")), "/")
|
||||
return "file://" + hostname + file_path
|
||||
}
|
||||
|
||||
write := func(items ...string) {
|
||||
for _, x := range items {
|
||||
os.Stdout.WriteString(x)
|
||||
}
|
||||
}
|
||||
|
||||
write_hyperlink := func(url, line, frag string) {
|
||||
write("\033]8;;", url)
|
||||
if frag != "" {
|
||||
write("#", frag)
|
||||
}
|
||||
write("\033\\", line, "\n\033]8;;\033\\")
|
||||
}
|
||||
|
||||
buf.process_line = func(line string) {
|
||||
line = osc_pat.ReplaceAllLiteralString(line, "") // remove existing hyperlinks
|
||||
clean_line := strings.TrimRightFunc(line, unicode.IsSpace)
|
||||
clean_line = sgr_pat.ReplaceAllLiteralString(clean_line, "") // remove SGR formatting
|
||||
if clean_line == "" {
|
||||
in_result = ""
|
||||
write("\n")
|
||||
} else if in_stats {
|
||||
write(line, "\n")
|
||||
} else if in_result != "" {
|
||||
if kitten_opts.line_number {
|
||||
m := num_pat.FindStringSubmatch(clean_line)
|
||||
if len(m) > 0 {
|
||||
is_match_line := len(m) > 1 && m[2] == ":"
|
||||
if (is_match_line && kitten_opts.matching_lines) || (!is_match_line && kitten_opts.context_lines) {
|
||||
write_hyperlink(in_result, line, m[1])
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
write(line, "\n")
|
||||
} else {
|
||||
if strings.TrimSpace(line) != "" {
|
||||
// The option priority should be consistent with ripgrep here.
|
||||
if kitten_opts.stats && !in_stats && stats_pat.MatchString(clean_line) {
|
||||
in_stats = true
|
||||
} else if kitten_opts.count || kitten_opts.count_matches {
|
||||
if m := path_with_count_pat.FindStringSubmatch(clean_line); len(m) > 0 && kitten_opts.file_headers {
|
||||
write_hyperlink(get_quoted_url(m[1]), line, "")
|
||||
return
|
||||
}
|
||||
} else if kitten_opts.files || kitten_opts.files_with_matches || kitten_opts.files_without_match {
|
||||
if kitten_opts.file_headers {
|
||||
write_hyperlink(get_quoted_url(clean_line), line, "")
|
||||
return
|
||||
}
|
||||
} else if kitten_opts.vimgrep || !kitten_opts.heading {
|
||||
var m []string
|
||||
// When the vimgrep option is present, it will take precedence.
|
||||
if kitten_opts.vimgrep {
|
||||
m = vimgrep_pat.FindStringSubmatch(clean_line)
|
||||
} else {
|
||||
m = path_with_linenum_pat.FindStringSubmatch(clean_line)
|
||||
}
|
||||
if len(m) > 0 && (kitten_opts.file_headers || kitten_opts.matching_lines) {
|
||||
write_hyperlink(get_quoted_url(m[1]), line, m[2])
|
||||
return
|
||||
}
|
||||
} else {
|
||||
in_result = get_quoted_url(clean_line)
|
||||
if kitten_opts.file_headers {
|
||||
write_hyperlink(in_result, line, "")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
write(line, "\n")
|
||||
}
|
||||
}
|
||||
|
||||
err = cmd.Run()
|
||||
var ee *exec.ExitError
|
||||
if err != nil {
|
||||
if errors.As(err, &ee) {
|
||||
return ee.ExitCode(), nil
|
||||
}
|
||||
return 1, fmt.Errorf("Failed to execute rg: %w", err)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func specialize_command(hg *cli.Command) {
|
||||
hg.Usage = "arguments for the rg command"
|
||||
hg.ShortDescription = "Add hyperlinks to the output of ripgrep"
|
||||
hg.HelpText = "The hyperlinked_grep kitten is a thin wrapper around the rg command. It automatically adds hyperlinks to the output of rg allowing the user to click on search results to have them open directly in their editor. For details on its usage, see :doc:`/kittens/hyperlinked_grep`."
|
||||
hg.IgnoreAllArgs = true
|
||||
hg.OnlyArgsAllowed = true
|
||||
hg.ArgCompleter = cli.CompletionForWrapper("rg")
|
||||
}
|
||||
|
||||
func EntryPoint(parent *cli.Command) {
|
||||
create_cmd(parent, main)
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
|
||||
|
||||
package hyperlinked_grep
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"kitty/tools/utils/shlex"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
)
|
||||
|
||||
var _ = fmt.Print
|
||||
|
||||
func TestRgArgParsing(t *testing.T) {
|
||||
if RgExe() == "rg" {
|
||||
t.Skip("Skipping as rg not found in PATH")
|
||||
}
|
||||
|
||||
check_failure := func(args ...string) {
|
||||
_, _, _, err := parse_args(args...)
|
||||
if err == nil {
|
||||
t.Fatalf("No error when parsing: %#v", args)
|
||||
}
|
||||
}
|
||||
check_failure("--kitten", "xyz")
|
||||
check_failure("--kitten", "xyz=1")
|
||||
|
||||
check_kitten_opts := func(matching, context, headers bool, args ...string) {
|
||||
_, _, kitten_opts, err := parse_args(args...)
|
||||
if err != nil {
|
||||
t.Fatalf("error when parsing: %#v: %s", args, err)
|
||||
}
|
||||
if matching != kitten_opts.matching_lines {
|
||||
t.Fatalf("Matching lines not correct for: %#v", args)
|
||||
}
|
||||
if context != kitten_opts.context_lines {
|
||||
t.Fatalf("Context lines not correct for: %#v", args)
|
||||
}
|
||||
if headers != kitten_opts.file_headers {
|
||||
t.Fatalf("File headers not correct for: %#v", args)
|
||||
}
|
||||
}
|
||||
check_kitten_opts(true, true, true)
|
||||
check_kitten_opts(false, false, false, "--kitten", "hyperlink=none")
|
||||
check_kitten_opts(false, false, true, "--kitten", "hyperlink=none", "--count", "--kitten=hyperlink=file_headers")
|
||||
check_kitten_opts(false, false, true, "--kitten", "hyperlink=none,file_headers")
|
||||
|
||||
check_kitten_opts = func(with_filename, heading, line_number bool, args ...string) {
|
||||
_, _, kitten_opts, err := parse_args(args...)
|
||||
if err != nil {
|
||||
t.Fatalf("error when parsing: %#v: %s", args, err)
|
||||
}
|
||||
if with_filename != kitten_opts.with_filename {
|
||||
t.Fatalf("with_filename not correct for: %#v", args)
|
||||
}
|
||||
if heading != kitten_opts.heading {
|
||||
t.Fatalf("heading not correct for: %#v", args)
|
||||
}
|
||||
if line_number != kitten_opts.line_number {
|
||||
t.Fatalf("line_number not correct for: %#v", args)
|
||||
}
|
||||
}
|
||||
|
||||
check_kitten_opts(true, true, true)
|
||||
check_kitten_opts(true, false, true, "--no-heading")
|
||||
check_kitten_opts(true, true, true, "--no-heading", "--pretty")
|
||||
check_kitten_opts(true, true, true, "--no-heading", "--heading")
|
||||
|
||||
check_args := func(args, expected string) {
|
||||
a, err := shlex.Split(args)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, actual, _, err := parse_args(a...)
|
||||
if err != nil {
|
||||
t.Fatalf("error when parsing: %#v: %s", args, err)
|
||||
}
|
||||
ex, err := shlex.Split(expected)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if diff := cmp.Diff(ex, actual); diff != "" {
|
||||
t.Fatalf("args not correct for %s\n%s", args, diff)
|
||||
}
|
||||
}
|
||||
check_args("--count --max-depth 10 --XxX yyy abcd", "--count --max-depth 10 --XxX yyy abcd")
|
||||
check_args("--max-depth=10 --kitten hyperlink=none abcd", "--max-depth=10 abcd")
|
||||
check_args("-m 10 abcd", "--max-count 10 abcd")
|
||||
check_args("-nm 10 abcd", "-n --max-count 10 abcd")
|
||||
check_args("-mn 10 abcd", "-n --max-count 10 abcd")
|
||||
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
|
||||
|
||||
package icat
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"kitty/tools/tui/graphics"
|
||||
"kitty/tools/tui/loop"
|
||||
"kitty/tools/utils"
|
||||
"kitty/tools/utils/images"
|
||||
"kitty/tools/utils/shm"
|
||||
)
|
||||
|
||||
var _ = fmt.Print
|
||||
|
||||
func DetectSupport(timeout time.Duration) (memory, files, direct bool, err error) {
|
||||
temp_files_to_delete := make([]string, 0, 8)
|
||||
shm_files_to_delete := make([]shm.MMap, 0, 8)
|
||||
var direct_query_id, file_query_id, memory_query_id uint32
|
||||
lp, e := loop.New(loop.NoAlternateScreen, loop.NoRestoreColors, loop.NoMouseTracking)
|
||||
if e != nil {
|
||||
err = e
|
||||
return
|
||||
}
|
||||
print_error := func(format string, args ...any) {
|
||||
lp.Println(fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if len(temp_files_to_delete) > 0 && transfer_by_file != supported {
|
||||
for _, name := range temp_files_to_delete {
|
||||
os.Remove(name)
|
||||
}
|
||||
}
|
||||
if len(shm_files_to_delete) > 0 && transfer_by_memory != supported {
|
||||
for _, name := range shm_files_to_delete {
|
||||
name.Unlink()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
lp.OnInitialize = func() (string, error) {
|
||||
var iid uint32
|
||||
lp.AddTimer(timeout, false, func(loop.IdType) error {
|
||||
return fmt.Errorf("Timed out waiting for a response form the terminal: %w", os.ErrDeadlineExceeded)
|
||||
})
|
||||
|
||||
g := func(t graphics.GRT_t, payload string) uint32 {
|
||||
iid += 1
|
||||
g1 := &graphics.GraphicsCommand{}
|
||||
g1.SetTransmission(t).SetAction(graphics.GRT_action_query).SetImageId(iid).SetDataWidth(1).SetDataHeight(1).SetFormat(
|
||||
graphics.GRT_format_rgb).SetDataSize(uint64(len(payload)))
|
||||
g1.WriteWithPayloadToLoop(lp, utils.UnsafeStringToBytes(payload))
|
||||
return iid
|
||||
}
|
||||
|
||||
direct_query_id = g(graphics.GRT_transmission_direct, "123")
|
||||
tf, err := images.CreateTempInRAM()
|
||||
if err == nil {
|
||||
file_query_id = g(graphics.GRT_transmission_tempfile, tf.Name())
|
||||
temp_files_to_delete = append(temp_files_to_delete, tf.Name())
|
||||
tf.Write([]byte{1, 2, 3})
|
||||
tf.Close()
|
||||
} else {
|
||||
print_error("Failed to create temporary file for data transfer, file based transfer is disabled. Error: %v", err)
|
||||
}
|
||||
sf, err := shm.CreateTemp("icat-", 3)
|
||||
if err == nil {
|
||||
memory_query_id = g(graphics.GRT_transmission_sharedmem, sf.Name())
|
||||
shm_files_to_delete = append(shm_files_to_delete, sf)
|
||||
copy(sf.Slice(), []byte{1, 2, 3})
|
||||
sf.Close()
|
||||
} else {
|
||||
var ens *shm.ErrNotSupported
|
||||
if !errors.As(err, &ens) {
|
||||
print_error("Failed to create SHM for data transfer, memory based transfer is disabled. Error: %v", err)
|
||||
}
|
||||
}
|
||||
lp.QueueWriteString("\x1b[c")
|
||||
|
||||
return "", nil
|
||||
}
|
||||
|
||||
lp.OnEscapeCode = func(etype loop.EscapeCodeType, payload []byte) (err error) {
|
||||
switch etype {
|
||||
case loop.CSI:
|
||||
if len(payload) > 3 && payload[0] == '?' && payload[len(payload)-1] == 'c' {
|
||||
lp.Quit(0)
|
||||
return nil
|
||||
}
|
||||
case loop.APC:
|
||||
g := graphics.GraphicsCommandFromAPC(payload)
|
||||
if g != nil {
|
||||
if g.ResponseMessage() == "OK" {
|
||||
switch g.ImageId() {
|
||||
case direct_query_id:
|
||||
direct = true
|
||||
case file_query_id:
|
||||
files = true
|
||||
case memory_query_id:
|
||||
memory = true
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
lp.OnKeyEvent = func(event *loop.KeyEvent) error {
|
||||
if event.MatchesPressOrRepeat("ctrl+c") {
|
||||
event.Handled = true
|
||||
print_error("Waiting for response from terminal, aborting now could lead to corruption")
|
||||
}
|
||||
if event.MatchesPressOrRepeat("ctrl+z") {
|
||||
event.Handled = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
err = lp.Run()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
ds := lp.DeathSignalName()
|
||||
if ds != "" {
|
||||
fmt.Println("Killed by signal: ", ds)
|
||||
lp.KillIfSignalled()
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
|
||||
|
||||
package icat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"kitty/tools/tui/graphics"
|
||||
"kitty/tools/utils/images"
|
||||
)
|
||||
|
||||
var _ = fmt.Print
|
||||
|
||||
func Render(path string, ro *images.RenderOptions, frames []images.IdentifyRecord) (ans []*image_frame, err error) {
|
||||
ro.TempfilenameTemplate = shm_template
|
||||
image_frames, filenames, err := images.RenderWithMagick(path, ro, frames)
|
||||
if err == nil {
|
||||
ans = make([]*image_frame, len(image_frames))
|
||||
for i, x := range image_frames {
|
||||
ans[i] = &image_frame{
|
||||
filename: filenames[x.Number], filename_is_temporary: true,
|
||||
number: x.Number, width: x.Width, height: x.Height, left: x.Left, top: x.Top,
|
||||
transmission_format: graphics.GRT_format_rgba, delay_ms: int(x.Delay_ms), compose_onto: x.Compose_onto,
|
||||
}
|
||||
if x.Is_opaque {
|
||||
ans[i].transmission_format = graphics.GRT_format_rgb
|
||||
}
|
||||
}
|
||||
}
|
||||
return ans, err
|
||||
}
|
||||
|
||||
func render_image_with_magick(imgd *image_data, src *opened_input) (err error) {
|
||||
err = src.PutOnFilesystem()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
frames, err := images.IdentifyWithMagick(src.FileSystemName())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
imgd.format_uppercase = frames[0].Fmt_uppercase
|
||||
imgd.canvas_width, imgd.canvas_height = frames[0].Canvas.Width, frames[0].Canvas.Height
|
||||
set_basic_metadata(imgd)
|
||||
if !imgd.needs_conversion {
|
||||
make_output_from_input(imgd, src)
|
||||
return nil
|
||||
}
|
||||
ro := images.RenderOptions{RemoveAlpha: remove_alpha, Flip: flip, Flop: flop}
|
||||
if scale_image(imgd) {
|
||||
ro.ResizeTo.X, ro.ResizeTo.Y = imgd.canvas_width, imgd.canvas_height
|
||||
}
|
||||
imgd.frames, err = Render(src.FileSystemName(), &ro, frames)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,278 +0,0 @@
|
||||
// License: GPLv3 Copyright: 2022, Kovid Goyal, <kovid at kovidgoyal.net>
|
||||
|
||||
package icat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"kitty/tools/cli"
|
||||
"kitty/tools/tty"
|
||||
"kitty/tools/tui"
|
||||
"kitty/tools/tui/graphics"
|
||||
"kitty/tools/utils"
|
||||
"kitty/tools/utils/images"
|
||||
"kitty/tools/utils/style"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
var _ = fmt.Print
|
||||
|
||||
type Place struct {
|
||||
width, height, left, top int
|
||||
}
|
||||
|
||||
var opts *Options
|
||||
var place *Place
|
||||
var z_index int32
|
||||
var remove_alpha *images.NRGBColor
|
||||
var flip, flop bool
|
||||
|
||||
type transfer_mode int
|
||||
|
||||
const (
|
||||
unknown transfer_mode = iota
|
||||
unsupported
|
||||
supported
|
||||
)
|
||||
|
||||
var transfer_by_file, transfer_by_memory, transfer_by_stream transfer_mode
|
||||
|
||||
var files_channel chan input_arg
|
||||
var output_channel chan *image_data
|
||||
var num_of_items int
|
||||
var keep_going *atomic.Bool
|
||||
var screen_size *unix.Winsize
|
||||
|
||||
func send_output(imgd *image_data) {
|
||||
output_channel <- imgd
|
||||
}
|
||||
|
||||
func parse_mirror() (err error) {
|
||||
flip = opts.Mirror == "both" || opts.Mirror == "vertical"
|
||||
flop = opts.Mirror == "both" || opts.Mirror == "horizontal"
|
||||
return
|
||||
}
|
||||
|
||||
func parse_background() (err error) {
|
||||
if opts.Background == "" || opts.Background == "none" {
|
||||
return nil
|
||||
}
|
||||
col, err := style.ParseColor(opts.Background)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Invalid value for --background: %w", err)
|
||||
}
|
||||
remove_alpha = &images.NRGBColor{R: col.Red, G: col.Green, B: col.Blue}
|
||||
return
|
||||
}
|
||||
|
||||
func parse_z_index() (err error) {
|
||||
val := opts.ZIndex
|
||||
var origin int32
|
||||
if strings.HasPrefix(val, "--") {
|
||||
origin = -1073741824
|
||||
val = val[1:]
|
||||
}
|
||||
i, err := strconv.ParseInt(val, 10, 32)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Invalid value for --z-index with error: %w", err)
|
||||
}
|
||||
z_index = int32(i) + origin
|
||||
return
|
||||
}
|
||||
|
||||
func parse_place() (err error) {
|
||||
if opts.Place == "" {
|
||||
return nil
|
||||
}
|
||||
area, pos, found := strings.Cut(opts.Place, "@")
|
||||
if !found {
|
||||
return fmt.Errorf("Invalid --place specification: %s", opts.Place)
|
||||
}
|
||||
w, h, found := strings.Cut(area, "x")
|
||||
if !found {
|
||||
return fmt.Errorf("Invalid --place specification: %s", opts.Place)
|
||||
}
|
||||
l, t, found := strings.Cut(pos, "x")
|
||||
if !found {
|
||||
return fmt.Errorf("Invalid --place specification: %s", opts.Place)
|
||||
}
|
||||
place = &Place{}
|
||||
place.width, err = strconv.Atoi(w)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
place.height, err = strconv.Atoi(h)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
place.left, err = strconv.Atoi(l)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
place.top, err = strconv.Atoi(t)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func print_error(format string, args ...any) {
|
||||
fmt.Fprintf(os.Stderr, format, args...)
|
||||
fmt.Fprintln(os.Stderr)
|
||||
}
|
||||
|
||||
func main(cmd *cli.Command, o *Options, args []string) (rc int, err error) {
|
||||
opts = o
|
||||
err = parse_place()
|
||||
if err != nil {
|
||||
return 1, err
|
||||
}
|
||||
err = parse_z_index()
|
||||
if err != nil {
|
||||
return 1, err
|
||||
}
|
||||
err = parse_background()
|
||||
if err != nil {
|
||||
return 1, err
|
||||
}
|
||||
err = parse_mirror()
|
||||
if err != nil {
|
||||
return 1, err
|
||||
}
|
||||
t, err := tty.OpenControllingTerm()
|
||||
if err != nil {
|
||||
return 1, fmt.Errorf("Failed to open controlling terminal with error: %w", err)
|
||||
}
|
||||
screen_size, err = t.GetSize()
|
||||
if err != nil {
|
||||
return 1, fmt.Errorf("Failed to query terminal using TIOCGWINSZ with error: %w", err)
|
||||
}
|
||||
|
||||
if opts.PrintWindowSize {
|
||||
fmt.Printf("%dx%d", screen_size.Xpixel, screen_size.Ypixel)
|
||||
return 0, nil
|
||||
}
|
||||
if opts.Clear {
|
||||
cc := &graphics.GraphicsCommand{}
|
||||
cc.SetAction(graphics.GRT_action_delete).SetDelete(graphics.GRT_free_visible)
|
||||
cc.WriteWithPayloadTo(os.Stdout, nil)
|
||||
}
|
||||
if screen_size.Xpixel == 0 || screen_size.Ypixel == 0 {
|
||||
return 1, fmt.Errorf("Terminal does not support reporting screen sizes in pixels, use a terminal such as kitty, WezTerm, Konsole, etc. that does.")
|
||||
}
|
||||
|
||||
items, err := process_dirs(args...)
|
||||
if err != nil {
|
||||
return 1, err
|
||||
}
|
||||
if opts.Place != "" && len(items) > 1 {
|
||||
return 1, fmt.Errorf("The --place option can only be used with a single image, not %d", len(items))
|
||||
}
|
||||
files_channel = make(chan input_arg, len(items))
|
||||
for _, ia := range items {
|
||||
files_channel <- ia
|
||||
}
|
||||
num_of_items = len(items)
|
||||
output_channel = make(chan *image_data, 1)
|
||||
keep_going = &atomic.Bool{}
|
||||
keep_going.Store(true)
|
||||
if !opts.DetectSupport && num_of_items > 0 {
|
||||
num_workers := utils.Max(1, utils.Min(num_of_items, runtime.NumCPU()))
|
||||
for i := 0; i < num_workers; i++ {
|
||||
go run_worker()
|
||||
}
|
||||
}
|
||||
|
||||
passthrough_mode := no_passthrough
|
||||
switch opts.Passthrough {
|
||||
case "tmux":
|
||||
passthrough_mode = tmux_passthrough
|
||||
case "detect":
|
||||
if tui.TmuxSocketAddress() != "" {
|
||||
passthrough_mode = tmux_passthrough
|
||||
}
|
||||
}
|
||||
|
||||
if passthrough_mode == no_passthrough && (opts.TransferMode == "detect" || opts.DetectSupport) {
|
||||
memory, files, direct, err := DetectSupport(time.Duration(opts.DetectionTimeout * float64(time.Second)))
|
||||
if err != nil {
|
||||
return 1, err
|
||||
}
|
||||
if !direct {
|
||||
keep_going.Store(false)
|
||||
return 1, fmt.Errorf("This terminal does not support the graphics protocol use a terminal such as kitty, WezTerm or Konsole that does. If you are running inside a terminal multiplexer such as tmux or screen that might be interfering as well.")
|
||||
}
|
||||
if memory {
|
||||
transfer_by_memory = supported
|
||||
} else {
|
||||
transfer_by_memory = unsupported
|
||||
}
|
||||
if files {
|
||||
transfer_by_file = supported
|
||||
} else {
|
||||
transfer_by_file = unsupported
|
||||
}
|
||||
}
|
||||
if passthrough_mode != no_passthrough {
|
||||
// tmux doesnt allow responses from the terminal so we cant detect if memory or file based transferring is supported
|
||||
transfer_by_memory = unsupported
|
||||
transfer_by_file = unsupported
|
||||
transfer_by_stream = supported
|
||||
}
|
||||
if opts.DetectSupport {
|
||||
if transfer_by_memory == supported {
|
||||
print_error("memory")
|
||||
} else if transfer_by_file == supported {
|
||||
print_error("files")
|
||||
} else {
|
||||
print_error("stream")
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
use_unicode_placeholder := opts.UnicodePlaceholder
|
||||
if passthrough_mode != no_passthrough {
|
||||
use_unicode_placeholder = true
|
||||
}
|
||||
base_id := uint32(opts.ImageId)
|
||||
for num_of_items > 0 {
|
||||
imgd := <-output_channel
|
||||
if base_id != 0 {
|
||||
imgd.image_id = base_id
|
||||
base_id++
|
||||
if base_id == 0 {
|
||||
base_id++
|
||||
}
|
||||
}
|
||||
imgd.use_unicode_placeholder = use_unicode_placeholder
|
||||
imgd.passthrough_mode = passthrough_mode
|
||||
num_of_items--
|
||||
if imgd.err != nil {
|
||||
print_error("Failed to process \x1b[31m%s\x1b[39m: %s\r\n", imgd.source_name, imgd.err)
|
||||
} else {
|
||||
transmit_image(imgd)
|
||||
if imgd.err != nil {
|
||||
print_error("Failed to transmit \x1b[31m%s\x1b[39m: %s\r\n", imgd.source_name, imgd.err)
|
||||
}
|
||||
}
|
||||
}
|
||||
keep_going.Store(false)
|
||||
if opts.Hold {
|
||||
fmt.Print("\r")
|
||||
if opts.Place != "" {
|
||||
fmt.Println()
|
||||
}
|
||||
tui.HoldTillEnter(false)
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func EntryPoint(parent *cli.Command) {
|
||||
create_cmd(parent, main)
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
|
||||
|
||||
package icat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"image/gif"
|
||||
"kitty/tools/tui/graphics"
|
||||
"kitty/tools/utils"
|
||||
"kitty/tools/utils/images"
|
||||
"kitty/tools/utils/shm"
|
||||
|
||||
"github.com/disintegration/imaging"
|
||||
)
|
||||
|
||||
var _ = fmt.Print
|
||||
|
||||
func resize_frame(imgd *image_data, img image.Image) (image.Image, image.Rectangle) {
|
||||
b := img.Bounds()
|
||||
left, top, width, height := b.Min.X, b.Min.Y, b.Dx(), b.Dy()
|
||||
new_width := int(imgd.scaled_frac.x * float64(width))
|
||||
new_height := int(imgd.scaled_frac.y * float64(height))
|
||||
img = imaging.Resize(img, new_width, new_height, imaging.Lanczos)
|
||||
newleft := int(imgd.scaled_frac.x * float64(left))
|
||||
newtop := int(imgd.scaled_frac.y * float64(top))
|
||||
return img, image.Rect(newleft, newtop, newleft+new_width, newtop+new_height)
|
||||
}
|
||||
|
||||
const shm_template = "kitty-icat-*"
|
||||
|
||||
func add_frame(ctx *images.Context, imgd *image_data, img image.Image) *image_frame {
|
||||
is_opaque := false
|
||||
if imgd.format_uppercase == "JPEG" {
|
||||
// special cased because EXIF orientation could have already changed this image to an NRGBA making IsOpaque() very slow
|
||||
is_opaque = true
|
||||
} else {
|
||||
is_opaque = images.IsOpaque(img)
|
||||
}
|
||||
b := img.Bounds()
|
||||
if imgd.scaled_frac.x != 0 {
|
||||
img, b = resize_frame(imgd, img)
|
||||
}
|
||||
f := image_frame{width: b.Dx(), height: b.Dy(), number: len(imgd.frames) + 1, left: b.Min.X, top: b.Min.Y}
|
||||
dest_rect := image.Rect(0, 0, f.width, f.height)
|
||||
var final_img image.Image
|
||||
bytes_per_pixel := 4
|
||||
|
||||
if is_opaque || remove_alpha != nil {
|
||||
var rgb *images.NRGB
|
||||
bytes_per_pixel = 3
|
||||
m, err := shm.CreateTemp(shm_template, uint64(f.width*f.height*bytes_per_pixel))
|
||||
if err != nil {
|
||||
rgb = images.NewNRGB(dest_rect)
|
||||
} else {
|
||||
rgb = &images.NRGB{Pix: m.Slice(), Stride: bytes_per_pixel * f.width, Rect: dest_rect}
|
||||
f.shm = m
|
||||
}
|
||||
f.transmission_format = graphics.GRT_format_rgb
|
||||
f.in_memory_bytes = rgb.Pix
|
||||
final_img = rgb
|
||||
} else {
|
||||
var rgba *image.NRGBA
|
||||
m, err := shm.CreateTemp(shm_template, uint64(f.width*f.height*bytes_per_pixel))
|
||||
if err != nil {
|
||||
rgba = image.NewNRGBA(dest_rect)
|
||||
} else {
|
||||
rgba = &image.NRGBA{Pix: m.Slice(), Stride: bytes_per_pixel * f.width, Rect: dest_rect}
|
||||
f.shm = m
|
||||
}
|
||||
f.transmission_format = graphics.GRT_format_rgba
|
||||
f.in_memory_bytes = rgba.Pix
|
||||
final_img = rgba
|
||||
}
|
||||
ctx.PasteCenter(final_img, img, remove_alpha)
|
||||
imgd.frames = append(imgd.frames, &f)
|
||||
if flip {
|
||||
ctx.FlipPixelsV(bytes_per_pixel, f.width, f.height, f.in_memory_bytes)
|
||||
if f.height < imgd.canvas_height {
|
||||
f.top = (2*imgd.canvas_height - f.height - f.top) % imgd.canvas_height
|
||||
}
|
||||
}
|
||||
if flop {
|
||||
ctx.FlipPixelsH(bytes_per_pixel, f.width, f.height, f.in_memory_bytes)
|
||||
if f.width < imgd.canvas_width {
|
||||
f.left = (2*imgd.canvas_width - f.width - f.left) % imgd.canvas_width
|
||||
}
|
||||
}
|
||||
return &f
|
||||
}
|
||||
|
||||
func scale_image(imgd *image_data) bool {
|
||||
if imgd.needs_scaling {
|
||||
width, height := imgd.canvas_width, imgd.canvas_height
|
||||
if imgd.canvas_width < imgd.available_width && opts.ScaleUp && place != nil {
|
||||
r := float64(imgd.available_width) / float64(imgd.canvas_width)
|
||||
imgd.canvas_width, imgd.canvas_height = imgd.available_width, int(r*float64(imgd.canvas_height))
|
||||
}
|
||||
neww, newh := images.FitImage(imgd.canvas_width, imgd.canvas_height, imgd.available_width, imgd.available_height)
|
||||
imgd.needs_scaling = false
|
||||
imgd.scaled_frac.x = float64(neww) / float64(width)
|
||||
imgd.scaled_frac.y = float64(newh) / float64(height)
|
||||
imgd.canvas_width = int(imgd.scaled_frac.x * float64(width))
|
||||
imgd.canvas_height = int(imgd.scaled_frac.y * float64(height))
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func load_one_frame_image(ctx *images.Context, imgd *image_data, src *opened_input) (img image.Image, err error) {
|
||||
img, err = imaging.Decode(src.file, imaging.AutoOrientation(true))
|
||||
src.Rewind()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
// reset the sizes as we read EXIF tags here which could have rotated the image
|
||||
imgd.canvas_width = img.Bounds().Dx()
|
||||
imgd.canvas_height = img.Bounds().Dy()
|
||||
set_basic_metadata(imgd)
|
||||
scale_image(imgd)
|
||||
return
|
||||
}
|
||||
|
||||
func calc_min_gap(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
|
||||
// Browsers actually force a 100ms gap at any zero gap frame, but that
|
||||
// just means it is impossible to deliberately use zero gap frames for
|
||||
// sophisticated blending, so we dont do that.
|
||||
max_gap := utils.Max(0, gaps...)
|
||||
min_gap := 0
|
||||
if max_gap <= 0 {
|
||||
min_gap = 10
|
||||
}
|
||||
return min_gap
|
||||
}
|
||||
|
||||
func (frame *image_frame) set_disposal(anchor_frame int, disposal byte) int {
|
||||
anchor_frame, frame.compose_onto = images.SetGIFFrameDisposal(frame.number, anchor_frame, disposal)
|
||||
return anchor_frame
|
||||
}
|
||||
|
||||
func (frame *image_frame) set_delay(gap, min_gap int) {
|
||||
frame.delay_ms = utils.Max(min_gap, gap) * 10
|
||||
if frame.delay_ms == 0 {
|
||||
frame.delay_ms = -1
|
||||
}
|
||||
}
|
||||
|
||||
func add_gif_frames(ctx *images.Context, imgd *image_data, gf *gif.GIF) error {
|
||||
min_gap := images.CalcMinimumGIFGap(gf.Delay)
|
||||
scale_image(imgd)
|
||||
anchor_frame := 1
|
||||
for i, paletted_img := range gf.Image {
|
||||
frame := add_frame(ctx, imgd, paletted_img)
|
||||
frame.set_delay(gf.Delay[i], min_gap)
|
||||
anchor_frame = frame.set_disposal(anchor_frame, gf.Disposal[i])
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func render_image_with_go(imgd *image_data, src *opened_input) (err error) {
|
||||
ctx := images.Context{}
|
||||
switch {
|
||||
case imgd.format_uppercase == "GIF" && opts.Loop != 0:
|
||||
gif_frames, err := gif.DecodeAll(src.file)
|
||||
src.Rewind()
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to decode GIF file with error: %w", err)
|
||||
}
|
||||
err = add_gif_frames(&ctx, imgd, gif_frames)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
img, err := load_one_frame_image(&ctx, imgd, src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
add_frame(&ctx, imgd, img)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,329 +0,0 @@
|
||||
// License: GPLv3 Copyright: 2022, Kovid Goyal, <kovid at kovidgoyal.net>
|
||||
|
||||
package icat
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"kitty/tools/tty"
|
||||
"kitty/tools/tui/graphics"
|
||||
"kitty/tools/utils"
|
||||
"kitty/tools/utils/images"
|
||||
"kitty/tools/utils/shm"
|
||||
)
|
||||
|
||||
var _ = fmt.Print
|
||||
|
||||
type BytesBuf struct {
|
||||
data []byte
|
||||
pos int64
|
||||
}
|
||||
|
||||
func (self *BytesBuf) Seek(offset int64, whence int) (int64, error) {
|
||||
switch whence {
|
||||
case io.SeekStart:
|
||||
self.pos = offset
|
||||
case io.SeekCurrent:
|
||||
self.pos += offset
|
||||
case io.SeekEnd:
|
||||
self.pos = int64(len(self.data)) + offset
|
||||
default:
|
||||
return self.pos, fmt.Errorf("Unknown value for whence: %#v", whence)
|
||||
}
|
||||
self.pos = utils.Max(0, utils.Min(self.pos, int64(len(self.data))))
|
||||
return self.pos, nil
|
||||
}
|
||||
|
||||
func (self *BytesBuf) Read(p []byte) (n int, err error) {
|
||||
nb := utils.Min(int64(len(p)), int64(len(self.data))-self.pos)
|
||||
if nb == 0 {
|
||||
err = io.EOF
|
||||
} else {
|
||||
n = copy(p, self.data[self.pos:self.pos+nb])
|
||||
self.pos += nb
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (self *BytesBuf) Close() error {
|
||||
self.data = nil
|
||||
self.pos = 0
|
||||
return nil
|
||||
}
|
||||
|
||||
type input_arg struct {
|
||||
arg string
|
||||
value string
|
||||
is_http_url bool
|
||||
}
|
||||
|
||||
func is_http_url(arg string) bool {
|
||||
return strings.HasPrefix(arg, "https://") || strings.HasPrefix(arg, "http://")
|
||||
}
|
||||
|
||||
func process_dirs(args ...string) (results []input_arg, err error) {
|
||||
results = make([]input_arg, 0, 64)
|
||||
if opts.Stdin != "no" && (opts.Stdin == "yes" || !tty.IsTerminal(os.Stdin.Fd())) {
|
||||
results = append(results, input_arg{arg: "/dev/stdin"})
|
||||
}
|
||||
for _, arg := range args {
|
||||
if arg != "" {
|
||||
if is_http_url(arg) {
|
||||
results = append(results, input_arg{arg: arg, value: arg, is_http_url: true})
|
||||
} else {
|
||||
if strings.HasPrefix(arg, "file://") {
|
||||
u, err := url.Parse(arg)
|
||||
if err != nil {
|
||||
return nil, &fs.PathError{Op: "Parse", Path: arg, Err: err}
|
||||
}
|
||||
arg = u.Path
|
||||
}
|
||||
s, err := os.Stat(arg)
|
||||
if err != nil {
|
||||
return nil, &fs.PathError{Op: "Stat", Path: arg, Err: err}
|
||||
}
|
||||
if s.IsDir() {
|
||||
filepath.WalkDir(arg, func(path string, d fs.DirEntry, walk_err error) error {
|
||||
if walk_err != nil {
|
||||
if d == nil {
|
||||
err = &fs.PathError{Op: "Stat", Path: arg, Err: walk_err}
|
||||
}
|
||||
return walk_err
|
||||
}
|
||||
if !d.IsDir() {
|
||||
mt := utils.GuessMimeType(path)
|
||||
if strings.HasPrefix(mt, "image/") {
|
||||
results = append(results, input_arg{arg: arg, value: path})
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
} else {
|
||||
results = append(results, input_arg{arg: arg, value: arg})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
type opened_input struct {
|
||||
file io.ReadSeekCloser
|
||||
name_to_unlink string
|
||||
}
|
||||
|
||||
func (self *opened_input) Rewind() {
|
||||
if self.file != nil {
|
||||
self.file.Seek(0, io.SeekStart)
|
||||
}
|
||||
}
|
||||
|
||||
func (self *opened_input) Release() {
|
||||
if self.file != nil {
|
||||
self.file.Close()
|
||||
self.file = nil
|
||||
}
|
||||
if self.name_to_unlink != "" {
|
||||
os.Remove(self.name_to_unlink)
|
||||
self.name_to_unlink = ""
|
||||
}
|
||||
}
|
||||
|
||||
func (self *opened_input) PutOnFilesystem() (err error) {
|
||||
if self.name_to_unlink != "" {
|
||||
return
|
||||
}
|
||||
f, err := images.CreateTempInRAM()
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to create a temporary file to store input data with error: %w", err)
|
||||
}
|
||||
self.Rewind()
|
||||
_, err = io.Copy(f, self.file)
|
||||
if err != nil {
|
||||
f.Close()
|
||||
return fmt.Errorf("Failed to copy input data to temporary file with error: %w", err)
|
||||
}
|
||||
self.Release()
|
||||
self.file = f
|
||||
self.name_to_unlink = f.Name()
|
||||
return
|
||||
}
|
||||
|
||||
func (self *opened_input) FileSystemName() string { return self.name_to_unlink }
|
||||
|
||||
type image_frame struct {
|
||||
filename string
|
||||
shm shm.MMap
|
||||
in_memory_bytes []byte
|
||||
filename_is_temporary bool
|
||||
width, height, left, top int
|
||||
transmission_format graphics.GRT_f
|
||||
compose_onto int
|
||||
number int
|
||||
disposal_background color.NRGBA
|
||||
delay_ms int
|
||||
}
|
||||
|
||||
type image_data struct {
|
||||
canvas_width, canvas_height int
|
||||
format_uppercase string
|
||||
available_width, available_height int
|
||||
needs_scaling, needs_conversion bool
|
||||
scaled_frac struct{ x, y float64 }
|
||||
frames []*image_frame
|
||||
image_number uint32
|
||||
image_id uint32
|
||||
cell_x_offset int
|
||||
move_x_by int
|
||||
move_to struct{ x, y int }
|
||||
width_cells, height_cells int
|
||||
use_unicode_placeholder bool
|
||||
passthrough_mode passthrough_type
|
||||
|
||||
// for error reporting
|
||||
err error
|
||||
source_name string
|
||||
}
|
||||
|
||||
func set_basic_metadata(imgd *image_data) {
|
||||
if imgd.frames == nil {
|
||||
imgd.frames = make([]*image_frame, 0, 32)
|
||||
}
|
||||
imgd.available_width = int(screen_size.Xpixel)
|
||||
imgd.available_height = 10 * imgd.canvas_height
|
||||
if place != nil {
|
||||
imgd.available_width = place.width * int(screen_size.Xpixel) / int(screen_size.Col)
|
||||
imgd.available_height = place.height * int(screen_size.Ypixel) / int(screen_size.Row)
|
||||
}
|
||||
imgd.needs_scaling = imgd.canvas_width > imgd.available_width || imgd.canvas_height > imgd.available_height || opts.ScaleUp
|
||||
imgd.needs_conversion = imgd.needs_scaling || remove_alpha != nil || flip || flop || imgd.format_uppercase != "PNG"
|
||||
}
|
||||
|
||||
func report_error(source_name, msg string, err error) {
|
||||
imgd := image_data{source_name: source_name, err: fmt.Errorf("%s: %w", msg, err)}
|
||||
send_output(&imgd)
|
||||
}
|
||||
|
||||
func make_output_from_input(imgd *image_data, f *opened_input) {
|
||||
bb, ok := f.file.(*BytesBuf)
|
||||
frame := image_frame{}
|
||||
imgd.frames = append(imgd.frames, &frame)
|
||||
frame.width = imgd.canvas_width
|
||||
frame.height = imgd.canvas_height
|
||||
if imgd.format_uppercase != "PNG" {
|
||||
panic(fmt.Sprintf("Unknown transmission format: %s", imgd.format_uppercase))
|
||||
}
|
||||
frame.transmission_format = graphics.GRT_format_png
|
||||
if ok {
|
||||
frame.in_memory_bytes = bb.data
|
||||
} else {
|
||||
frame.filename = f.file.(*os.File).Name()
|
||||
if f.name_to_unlink != "" {
|
||||
frame.filename_is_temporary = true
|
||||
f.name_to_unlink = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func process_arg(arg input_arg) {
|
||||
var f opened_input
|
||||
if arg.is_http_url {
|
||||
resp, err := http.Get(arg.value)
|
||||
if err != nil {
|
||||
report_error(arg.value, "Could not get", err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
report_error(arg.value, "Could not get", fmt.Errorf("bad status: %v", resp.Status))
|
||||
return
|
||||
}
|
||||
dest := bytes.Buffer{}
|
||||
dest.Grow(64 * 1024)
|
||||
_, err = io.Copy(&dest, resp.Body)
|
||||
if err != nil {
|
||||
report_error(arg.value, "Could not download", err)
|
||||
return
|
||||
}
|
||||
f.file = &BytesBuf{data: dest.Bytes()}
|
||||
} else if arg.value == "" {
|
||||
stdin, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
report_error("<stdin>", "Could not read from", err)
|
||||
return
|
||||
}
|
||||
f.file = &BytesBuf{data: stdin}
|
||||
} else {
|
||||
q, err := os.Open(arg.value)
|
||||
if err != nil {
|
||||
report_error(arg.value, "Could not open", err)
|
||||
return
|
||||
}
|
||||
f.file = q
|
||||
}
|
||||
defer f.Release()
|
||||
can_use_go := false
|
||||
var c image.Config
|
||||
var format string
|
||||
var err error
|
||||
imgd := image_data{source_name: arg.value}
|
||||
if opts.Engine == "auto" || opts.Engine == "native" {
|
||||
c, format, err = image.DecodeConfig(f.file)
|
||||
f.Rewind()
|
||||
can_use_go = err == nil
|
||||
}
|
||||
if !keep_going.Load() {
|
||||
return
|
||||
}
|
||||
if can_use_go {
|
||||
imgd.canvas_width = c.Width
|
||||
imgd.canvas_height = c.Height
|
||||
imgd.format_uppercase = strings.ToUpper(format)
|
||||
set_basic_metadata(&imgd)
|
||||
if !imgd.needs_conversion {
|
||||
make_output_from_input(&imgd, &f)
|
||||
send_output(&imgd)
|
||||
return
|
||||
}
|
||||
err = render_image_with_go(&imgd, &f)
|
||||
if err != nil {
|
||||
report_error(arg.value, "Could not render image to RGB", err)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
err = render_image_with_magick(&imgd, &f)
|
||||
if err != nil {
|
||||
report_error(arg.value, "ImageMagick failed", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if !keep_going.Load() {
|
||||
return
|
||||
}
|
||||
send_output(&imgd)
|
||||
|
||||
}
|
||||
|
||||
func run_worker() {
|
||||
for {
|
||||
select {
|
||||
case arg := <-files_channel:
|
||||
if !keep_going.Load() {
|
||||
return
|
||||
}
|
||||
process_arg(arg)
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,408 +0,0 @@
|
||||
// License: GPLv3 Copyright: 2022, Kovid Goyal, <kovid at kovidgoyal.net>
|
||||
|
||||
package icat
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"kitty"
|
||||
"math"
|
||||
not_rand "math/rand"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"kitty/tools/tui"
|
||||
"kitty/tools/tui/graphics"
|
||||
"kitty/tools/tui/loop"
|
||||
"kitty/tools/utils"
|
||||
"kitty/tools/utils/images"
|
||||
"kitty/tools/utils/shm"
|
||||
)
|
||||
|
||||
var _ = fmt.Print
|
||||
|
||||
type passthrough_type int
|
||||
|
||||
const (
|
||||
no_passthrough passthrough_type = iota
|
||||
tmux_passthrough
|
||||
)
|
||||
|
||||
func new_graphics_command(imgd *image_data) *graphics.GraphicsCommand {
|
||||
gc := graphics.GraphicsCommand{}
|
||||
switch imgd.passthrough_mode {
|
||||
case tmux_passthrough:
|
||||
gc.WrapPrefix = "\033Ptmux;"
|
||||
gc.WrapSuffix = "\033\\"
|
||||
gc.EncodeSerializedDataFunc = func(x string) string { return strings.ReplaceAll(x, "\033", "\033\033") }
|
||||
}
|
||||
return &gc
|
||||
}
|
||||
|
||||
func gc_for_image(imgd *image_data, frame_num int, frame *image_frame) *graphics.GraphicsCommand {
|
||||
gc := new_graphics_command(imgd)
|
||||
gc.SetDataWidth(uint64(frame.width)).SetDataHeight(uint64(frame.height))
|
||||
gc.SetQuiet(graphics.GRT_quiet_silent)
|
||||
gc.SetFormat(frame.transmission_format)
|
||||
if imgd.image_number != 0 {
|
||||
gc.SetImageNumber(imgd.image_number)
|
||||
}
|
||||
if imgd.image_id != 0 {
|
||||
gc.SetImageId(imgd.image_id)
|
||||
}
|
||||
if frame_num == 0 {
|
||||
gc.SetAction(graphics.GRT_action_transmit_and_display)
|
||||
if imgd.use_unicode_placeholder {
|
||||
gc.SetUnicodePlaceholder(graphics.GRT_create_unicode_placeholder)
|
||||
gc.SetColumns(uint64(imgd.width_cells))
|
||||
gc.SetRows(uint64(imgd.height_cells))
|
||||
}
|
||||
if imgd.cell_x_offset > 0 {
|
||||
gc.SetXOffset(uint64(imgd.cell_x_offset))
|
||||
}
|
||||
if z_index != 0 {
|
||||
gc.SetZIndex(z_index)
|
||||
}
|
||||
if place != nil {
|
||||
gc.SetCursorMovement(graphics.GRT_cursor_static)
|
||||
}
|
||||
} else {
|
||||
gc.SetAction(graphics.GRT_action_frame)
|
||||
gc.SetGap(int32(frame.delay_ms))
|
||||
if frame.compose_onto > 0 {
|
||||
gc.SetOverlaidFrame(uint64(frame.compose_onto))
|
||||
} else {
|
||||
bg := (uint32(frame.disposal_background.R) << 24) | (uint32(frame.disposal_background.G) << 16) | (uint32(frame.disposal_background.B) << 8) | uint32(frame.disposal_background.A)
|
||||
gc.SetBackgroundColor(bg)
|
||||
}
|
||||
gc.SetLeftEdge(uint64(frame.left)).SetTopEdge(uint64(frame.top))
|
||||
}
|
||||
return gc
|
||||
}
|
||||
|
||||
func transmit_shm(imgd *image_data, frame_num int, frame *image_frame) (err error) {
|
||||
var mmap shm.MMap
|
||||
var data_size int64
|
||||
if frame.in_memory_bytes == nil {
|
||||
f, err := os.Open(frame.filename)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to open image data output file: %s with error: %w", frame.filename, err)
|
||||
}
|
||||
defer f.Close()
|
||||
data_size, _ = f.Seek(0, io.SeekEnd)
|
||||
f.Seek(0, io.SeekStart)
|
||||
mmap, err = shm.CreateTemp("icat-*", uint64(data_size))
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to create a SHM file for transmission: %w", err)
|
||||
}
|
||||
dest := mmap.Slice()
|
||||
for len(dest) > 0 {
|
||||
n, err := f.Read(dest)
|
||||
dest = dest[n:]
|
||||
if err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
mmap.Unlink()
|
||||
return fmt.Errorf("Failed to read data from image output data file: %w", err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if frame.shm == nil {
|
||||
data_size = int64(len(frame.in_memory_bytes))
|
||||
mmap, err = shm.CreateTemp("icat-*", uint64(data_size))
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to create a SHM file for transmission: %w", err)
|
||||
}
|
||||
copy(mmap.Slice(), frame.in_memory_bytes)
|
||||
} else {
|
||||
mmap = frame.shm
|
||||
frame.shm = nil
|
||||
}
|
||||
}
|
||||
gc := gc_for_image(imgd, frame_num, frame)
|
||||
gc.SetTransmission(graphics.GRT_transmission_sharedmem)
|
||||
gc.SetDataSize(uint64(data_size))
|
||||
gc.WriteWithPayloadTo(os.Stdout, utils.UnsafeStringToBytes(mmap.Name()))
|
||||
mmap.Close()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func transmit_file(imgd *image_data, frame_num int, frame *image_frame) (err error) {
|
||||
is_temp := false
|
||||
fname := ""
|
||||
var data_size int
|
||||
if frame.in_memory_bytes == nil {
|
||||
is_temp = frame.filename_is_temporary
|
||||
fname, err = filepath.Abs(frame.filename)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to convert image data output file: %s to absolute path with error: %w", frame.filename, err)
|
||||
}
|
||||
frame.filename = "" // so it isnt deleted in cleanup
|
||||
} else {
|
||||
is_temp = true
|
||||
if frame.shm != nil && frame.shm.FileSystemName() != "" {
|
||||
fname = frame.shm.FileSystemName()
|
||||
frame.shm.Close()
|
||||
frame.shm = nil
|
||||
} else {
|
||||
f, err := images.CreateTempInRAM()
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to create a temp file for image data transmission: %w", err)
|
||||
}
|
||||
data_size = len(frame.in_memory_bytes)
|
||||
_, err = bytes.NewBuffer(frame.in_memory_bytes).WriteTo(f)
|
||||
f.Close()
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to write image data to temp file for transmission: %w", err)
|
||||
}
|
||||
fname = f.Name()
|
||||
}
|
||||
}
|
||||
gc := gc_for_image(imgd, frame_num, frame)
|
||||
if is_temp {
|
||||
gc.SetTransmission(graphics.GRT_transmission_tempfile)
|
||||
} else {
|
||||
gc.SetTransmission(graphics.GRT_transmission_file)
|
||||
}
|
||||
if data_size > 0 {
|
||||
gc.SetDataSize(uint64(data_size))
|
||||
}
|
||||
gc.WriteWithPayloadTo(os.Stdout, utils.UnsafeStringToBytes(fname))
|
||||
return nil
|
||||
}
|
||||
|
||||
func transmit_stream(imgd *image_data, frame_num int, frame *image_frame) (err error) {
|
||||
data := frame.in_memory_bytes
|
||||
if data == nil {
|
||||
f, err := os.Open(frame.filename)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to open image data output file: %s with error: %w", frame.filename, err)
|
||||
}
|
||||
data, err = io.ReadAll(f)
|
||||
f.Close()
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to read data from image output data file: %w", err)
|
||||
}
|
||||
}
|
||||
gc := gc_for_image(imgd, frame_num, frame)
|
||||
gc.WriteWithPayloadTo(os.Stdout, data)
|
||||
return nil
|
||||
}
|
||||
|
||||
func calculate_in_cell_x_offset(width, cell_width int) int {
|
||||
extra_pixels := width % cell_width
|
||||
if extra_pixels == 0 {
|
||||
return 0
|
||||
}
|
||||
switch opts.Align {
|
||||
case "left":
|
||||
return 0
|
||||
case "right":
|
||||
return cell_width - extra_pixels
|
||||
default:
|
||||
return (cell_width - extra_pixels) / 2
|
||||
}
|
||||
}
|
||||
|
||||
func place_cursor(imgd *image_data) {
|
||||
cw := int(screen_size.Xpixel) / int(screen_size.Col)
|
||||
ch := int(screen_size.Ypixel) / int(screen_size.Row)
|
||||
imgd.cell_x_offset = calculate_in_cell_x_offset(imgd.canvas_width, cw)
|
||||
imgd.width_cells = int(math.Ceil(float64(imgd.canvas_width) / float64(cw)))
|
||||
imgd.height_cells = int(math.Ceil(float64(imgd.canvas_height) / float64(ch)))
|
||||
if place == nil {
|
||||
switch opts.Align {
|
||||
case "center":
|
||||
imgd.move_x_by = (int(screen_size.Col) - imgd.width_cells) / 2
|
||||
case "right":
|
||||
imgd.move_x_by = (int(screen_size.Col) - imgd.width_cells)
|
||||
}
|
||||
} else {
|
||||
imgd.move_to.x = place.left + 1
|
||||
imgd.move_to.y = place.top + 1
|
||||
switch opts.Align {
|
||||
case "center":
|
||||
imgd.move_to.x += (place.width - imgd.width_cells) / 2
|
||||
case "right":
|
||||
imgd.move_to.x += (place.width - imgd.width_cells)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func next_random() (ans uint32) {
|
||||
for ans == 0 {
|
||||
b := make([]byte, 4)
|
||||
_, err := rand.Read(b)
|
||||
if err == nil {
|
||||
ans = binary.LittleEndian.Uint32(b[:])
|
||||
} else {
|
||||
ans = not_rand.Uint32()
|
||||
}
|
||||
}
|
||||
return ans
|
||||
}
|
||||
|
||||
func write_unicode_placeholder(imgd *image_data) {
|
||||
prefix := ""
|
||||
foreground := fmt.Sprintf("\033[38:2:%d:%d:%dm", (imgd.image_id>>16)&255, (imgd.image_id>>8)&255, imgd.image_id&255)
|
||||
os.Stdout.WriteString(foreground)
|
||||
restore := "\033[39m"
|
||||
if imgd.move_to.y > 0 {
|
||||
os.Stdout.WriteString(loop.SAVE_CURSOR)
|
||||
restore += loop.RESTORE_CURSOR
|
||||
} else if imgd.move_x_by > 0 {
|
||||
prefix = strings.Repeat(" ", imgd.move_x_by)
|
||||
}
|
||||
defer func() { os.Stdout.WriteString(restore) }()
|
||||
if imgd.move_to.y > 0 {
|
||||
fmt.Printf(loop.MoveCursorToTemplate, imgd.move_to.y, 0)
|
||||
}
|
||||
id_char := string(images.NumberToDiacritic[(imgd.image_id>>24)&255])
|
||||
for r := 0; r < imgd.height_cells; r++ {
|
||||
if imgd.move_to.x > 0 {
|
||||
fmt.Printf("\x1b[%dC", imgd.move_to.x)
|
||||
} else {
|
||||
os.Stdout.WriteString(prefix)
|
||||
}
|
||||
for c := 0; c < imgd.width_cells; c++ {
|
||||
os.Stdout.WriteString(string(kitty.ImagePlaceholderChar) + string(images.NumberToDiacritic[r]) + string(images.NumberToDiacritic[c]) + id_char)
|
||||
}
|
||||
os.Stdout.WriteString("\n\r")
|
||||
}
|
||||
}
|
||||
|
||||
var seen_image_ids *utils.Set[uint32]
|
||||
|
||||
func transmit_image(imgd *image_data) {
|
||||
if seen_image_ids == nil {
|
||||
seen_image_ids = utils.NewSet[uint32](32)
|
||||
}
|
||||
defer func() {
|
||||
for _, frame := range imgd.frames {
|
||||
if frame.filename_is_temporary && frame.filename != "" {
|
||||
os.Remove(frame.filename)
|
||||
frame.filename = ""
|
||||
}
|
||||
if frame.shm != nil {
|
||||
frame.shm.Unlink()
|
||||
frame.shm.Close()
|
||||
frame.shm = nil
|
||||
}
|
||||
frame.in_memory_bytes = nil
|
||||
}
|
||||
}()
|
||||
var f func(*image_data, int, *image_frame) error
|
||||
if opts.TransferMode != "detect" {
|
||||
switch opts.TransferMode {
|
||||
case "file":
|
||||
f = transmit_file
|
||||
case "memory":
|
||||
f = transmit_shm
|
||||
case "stream":
|
||||
f = transmit_stream
|
||||
}
|
||||
}
|
||||
if f == nil && transfer_by_memory == supported && imgd.frames[0].in_memory_bytes != nil {
|
||||
f = transmit_shm
|
||||
}
|
||||
if f == nil && transfer_by_file == supported {
|
||||
f = transmit_file
|
||||
}
|
||||
if f == nil {
|
||||
f = transmit_stream
|
||||
}
|
||||
if imgd.image_id == 0 {
|
||||
if imgd.use_unicode_placeholder {
|
||||
for imgd.image_id&0xFF000000 == 0 || imgd.image_id&0x00FFFF00 == 0 || seen_image_ids.Has(imgd.image_id) {
|
||||
// Generate a 32-bit image id using rejection sampling such that the most
|
||||
// significant byte and the two bytes in the middle are non-zero to avoid
|
||||
// collisions with applications that cannot represent non-zero most
|
||||
// significant bytes (which is represented by the third combining character)
|
||||
// or two non-zero bytes in the middle (which requires 24-bit color mode).
|
||||
imgd.image_id = next_random()
|
||||
}
|
||||
seen_image_ids.Add(imgd.image_id)
|
||||
} else {
|
||||
if len(imgd.frames) > 1 {
|
||||
for imgd.image_number == 0 {
|
||||
imgd.image_number = next_random()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
place_cursor(imgd)
|
||||
if imgd.use_unicode_placeholder && utils.Max(imgd.width_cells, imgd.height_cells) >= len(images.NumberToDiacritic) {
|
||||
imgd.err = fmt.Errorf("Image too large to be displayed using Unicode placeholders. Maximum size is %dx%d cells", len(images.NumberToDiacritic), len(images.NumberToDiacritic))
|
||||
return
|
||||
}
|
||||
switch imgd.passthrough_mode {
|
||||
case tmux_passthrough:
|
||||
imgd.err = tui.TmuxAllowPassthrough()
|
||||
if imgd.err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
fmt.Print("\r")
|
||||
if !imgd.use_unicode_placeholder {
|
||||
if imgd.move_x_by > 0 {
|
||||
fmt.Printf("\x1b[%dC", imgd.move_x_by)
|
||||
}
|
||||
if imgd.move_to.x > 0 {
|
||||
fmt.Printf(loop.MoveCursorToTemplate, imgd.move_to.y, imgd.move_to.x)
|
||||
}
|
||||
}
|
||||
frame_control_cmd := new_graphics_command(imgd)
|
||||
frame_control_cmd.SetAction(graphics.GRT_action_animate)
|
||||
if imgd.image_id != 0 {
|
||||
frame_control_cmd.SetImageId(imgd.image_id)
|
||||
} else {
|
||||
frame_control_cmd.SetImageNumber(imgd.image_number)
|
||||
}
|
||||
is_animated := len(imgd.frames) > 1
|
||||
|
||||
for frame_num, frame := range imgd.frames {
|
||||
err := f(imgd, frame_num, frame)
|
||||
if err != nil {
|
||||
imgd.err = err
|
||||
return
|
||||
}
|
||||
if is_animated {
|
||||
switch frame_num {
|
||||
case 0:
|
||||
// set gap for the first frame and number of loops for the animation
|
||||
c := frame_control_cmd
|
||||
c.SetTargetFrame(uint64(frame.number))
|
||||
c.SetGap(int32(frame.delay_ms))
|
||||
switch {
|
||||
case opts.Loop < 0:
|
||||
c.SetNumberOfLoops(1)
|
||||
case opts.Loop > 0:
|
||||
c.SetNumberOfLoops(uint64(opts.Loop) + 1)
|
||||
}
|
||||
c.WriteWithPayloadTo(os.Stdout, nil)
|
||||
case 1:
|
||||
c := frame_control_cmd
|
||||
c.SetAnimationControl(2) // set animation to loading mode
|
||||
c.WriteWithPayloadTo(os.Stdout, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
if imgd.use_unicode_placeholder {
|
||||
write_unicode_placeholder(imgd)
|
||||
}
|
||||
if is_animated {
|
||||
c := frame_control_cmd
|
||||
c.SetAnimationControl(3) // set animation to normal mode
|
||||
c.WriteWithPayloadTo(os.Stdout, nil)
|
||||
}
|
||||
if imgd.move_to.x == 0 {
|
||||
fmt.Println() // ensure cursor is on new line
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,9 @@ package main
|
||||
import (
|
||||
"os"
|
||||
|
||||
"kitty/kittens/ssh"
|
||||
"kitty/tools/cli"
|
||||
"kitty/tools/cmd/completion"
|
||||
"kitty/tools/cmd/ssh"
|
||||
"kitty/tools/cmd/tool"
|
||||
)
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@ package pytest
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"kitty/kittens/ssh"
|
||||
"kitty/tools/cli"
|
||||
"kitty/tools/cmd/ssh"
|
||||
"kitty/tools/utils/shm"
|
||||
)
|
||||
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
|
||||
|
||||
package ssh
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"kitty/tools/cli"
|
||||
"kitty/tools/tty"
|
||||
"kitty/tools/utils/shm"
|
||||
)
|
||||
|
||||
var _ = fmt.Print
|
||||
|
||||
func fatal(err error) {
|
||||
cli.ShowError(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
func trigger_ask(name string) {
|
||||
term, err := tty.OpenControllingTerm()
|
||||
if err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
defer term.Close()
|
||||
_, err = term.WriteString("\x1bP@kitty-ask|" + name + "\x1b\\")
|
||||
if err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func RunSSHAskpass() {
|
||||
msg := os.Args[len(os.Args)-1]
|
||||
prompt := os.Getenv("SSH_ASKPASS_PROMPT")
|
||||
is_confirm := prompt == "confirm"
|
||||
q_type := "get_line"
|
||||
if is_confirm {
|
||||
q_type = "confirm"
|
||||
}
|
||||
is_fingerprint_check := strings.Contains(msg, "(yes/no/[fingerprint])")
|
||||
q := map[string]any{
|
||||
"message": msg,
|
||||
"type": q_type,
|
||||
"is_password": !is_fingerprint_check,
|
||||
}
|
||||
data, err := json.Marshal(q)
|
||||
if err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
data_shm, err := shm.CreateTemp("askpass-*", uint64(len(data)+32))
|
||||
if err != nil {
|
||||
fatal(fmt.Errorf("Failed to create SHM file with error: %w", err))
|
||||
}
|
||||
defer data_shm.Close()
|
||||
defer data_shm.Unlink()
|
||||
|
||||
data_shm.Slice()[0] = 0
|
||||
shm.WriteWithSize(data_shm, data, 1)
|
||||
err = data_shm.Flush()
|
||||
if err != nil {
|
||||
fatal(fmt.Errorf("Failed to flush SHM file with error: %w", err))
|
||||
}
|
||||
trigger_ask(data_shm.Name())
|
||||
for {
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if data_shm.Slice()[0] == 1 {
|
||||
break
|
||||
}
|
||||
}
|
||||
data, err = shm.ReadWithSize(data_shm, 1)
|
||||
if err != nil {
|
||||
fatal(fmt.Errorf("Failed to read from SHM file with error: %w", err))
|
||||
}
|
||||
response := ""
|
||||
if is_confirm {
|
||||
var ok bool
|
||||
err = json.Unmarshal(data, &ok)
|
||||
if err != nil {
|
||||
fatal(fmt.Errorf("Failed to parse response data: %#v with error: %w", string(data), err))
|
||||
}
|
||||
response = "no"
|
||||
if ok {
|
||||
response = "yes"
|
||||
}
|
||||
} else {
|
||||
err = json.Unmarshal(data, &response)
|
||||
if err != nil {
|
||||
fatal(fmt.Errorf("Failed to parse response data: %#v with error: %w", string(data), err))
|
||||
}
|
||||
if is_fingerprint_check {
|
||||
response = strings.ToLower(response)
|
||||
if response == "y" {
|
||||
response = "yes"
|
||||
} else if response == "n" {
|
||||
response = "no"
|
||||
}
|
||||
}
|
||||
}
|
||||
if response != "" {
|
||||
fmt.Println(response)
|
||||
}
|
||||
}
|
||||
@@ -1,399 +0,0 @@
|
||||
// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
|
||||
|
||||
package ssh
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"kitty/tools/config"
|
||||
"kitty/tools/utils"
|
||||
"kitty/tools/utils/paths"
|
||||
"kitty/tools/utils/shlex"
|
||||
|
||||
"github.com/bmatcuk/doublestar"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
var _ = fmt.Print
|
||||
|
||||
type EnvInstruction struct {
|
||||
key, val string
|
||||
delete_on_remote, copy_from_local, literal_quote bool
|
||||
}
|
||||
|
||||
func quote_for_sh(val string, literal_quote bool) string {
|
||||
if literal_quote {
|
||||
return utils.QuoteStringForSH(val)
|
||||
}
|
||||
// See https://www.gnu.org/software/bash/manual/html_node/Double-Quotes.html
|
||||
b := strings.Builder{}
|
||||
b.Grow(len(val) + 16)
|
||||
b.WriteRune('"')
|
||||
runes := []rune(val)
|
||||
for i, ch := range runes {
|
||||
if ch == '\\' || ch == '`' || ch == '"' || (ch == '$' && i+1 < len(runes) && runes[i+1] == '(') {
|
||||
// special chars are escaped
|
||||
// $( is escaped to prevent execution
|
||||
b.WriteRune('\\')
|
||||
}
|
||||
b.WriteRune(ch)
|
||||
}
|
||||
b.WriteRune('"')
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (self *EnvInstruction) Serialize(for_python bool, get_local_env func(string) (string, bool)) string {
|
||||
var unset func() string
|
||||
var export func(string) string
|
||||
if for_python {
|
||||
dumps := func(x ...any) string {
|
||||
ans, _ := json.Marshal(x)
|
||||
return utils.UnsafeBytesToString(ans)
|
||||
}
|
||||
export = func(val string) string {
|
||||
if val == "" {
|
||||
return fmt.Sprintf("export %s", dumps(self.key))
|
||||
}
|
||||
return fmt.Sprintf("export %s", dumps(self.key, val, self.literal_quote))
|
||||
}
|
||||
unset = func() string {
|
||||
return fmt.Sprintf("unset %s", dumps(self.key))
|
||||
}
|
||||
} else {
|
||||
kq := utils.QuoteStringForSH(self.key)
|
||||
unset = func() string {
|
||||
return fmt.Sprintf("unset %s", kq)
|
||||
}
|
||||
export = func(val string) string {
|
||||
return fmt.Sprintf("export %s=%s", kq, quote_for_sh(val, self.literal_quote))
|
||||
}
|
||||
}
|
||||
if self.delete_on_remote {
|
||||
return unset()
|
||||
}
|
||||
if self.copy_from_local {
|
||||
val, found := get_local_env(self.key)
|
||||
if !found {
|
||||
return ""
|
||||
}
|
||||
return export(val)
|
||||
}
|
||||
return export(self.val)
|
||||
}
|
||||
|
||||
func final_env_instructions(for_python bool, get_local_env func(string) (string, bool), env ...*EnvInstruction) string {
|
||||
seen := make(map[string]int, len(env))
|
||||
ans := make([]string, 0, len(env))
|
||||
for _, ei := range env {
|
||||
q := ei.Serialize(for_python, get_local_env)
|
||||
if q != "" {
|
||||
if pos, found := seen[ei.key]; found {
|
||||
ans[pos] = q
|
||||
} else {
|
||||
seen[ei.key] = len(ans)
|
||||
ans = append(ans, q)
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.Join(ans, "\n")
|
||||
}
|
||||
|
||||
type CopyInstruction struct {
|
||||
local_path, arcname string
|
||||
exclude_patterns []string
|
||||
}
|
||||
|
||||
func ParseEnvInstruction(spec string) (ans []*EnvInstruction, err error) {
|
||||
const COPY_FROM_LOCAL string = "_kitty_copy_env_var_"
|
||||
ei := &EnvInstruction{}
|
||||
found := false
|
||||
ei.key, ei.val, found = strings.Cut(spec, "=")
|
||||
ei.key = strings.TrimSpace(ei.key)
|
||||
if found {
|
||||
ei.val = strings.TrimSpace(ei.val)
|
||||
if ei.val == COPY_FROM_LOCAL {
|
||||
ei.val = ""
|
||||
ei.copy_from_local = true
|
||||
}
|
||||
} else {
|
||||
ei.delete_on_remote = true
|
||||
}
|
||||
if ei.key == "" {
|
||||
err = fmt.Errorf("The env directive must not be empty")
|
||||
}
|
||||
ans = []*EnvInstruction{ei}
|
||||
return
|
||||
}
|
||||
|
||||
var paths_ctx *paths.Ctx
|
||||
|
||||
func resolve_file_spec(spec string, is_glob bool) ([]string, error) {
|
||||
if paths_ctx == nil {
|
||||
paths_ctx = &paths.Ctx{}
|
||||
}
|
||||
ans := os.ExpandEnv(paths_ctx.ExpandHome(spec))
|
||||
if !filepath.IsAbs(ans) {
|
||||
ans = paths_ctx.AbspathFromHome(ans)
|
||||
}
|
||||
if is_glob {
|
||||
files, err := doublestar.Glob(ans)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s is not a valid glob pattern with error: %w", spec, err)
|
||||
}
|
||||
if len(files) == 0 {
|
||||
return nil, fmt.Errorf("%s matches no files", spec)
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
err := unix.Access(ans, unix.R_OK)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, fmt.Errorf("%s does not exist", spec)
|
||||
}
|
||||
return nil, fmt.Errorf("Cannot read from: %s with error: %w", spec, err)
|
||||
}
|
||||
return []string{ans}, nil
|
||||
}
|
||||
|
||||
func get_arcname(loc, dest, home string) (arcname string) {
|
||||
if dest != "" {
|
||||
arcname = dest
|
||||
} else {
|
||||
arcname = filepath.Clean(loc)
|
||||
if filepath.HasPrefix(arcname, home) {
|
||||
ra, err := filepath.Rel(home, arcname)
|
||||
if err == nil {
|
||||
arcname = ra
|
||||
}
|
||||
}
|
||||
}
|
||||
prefix := "home/"
|
||||
if strings.HasPrefix(arcname, "/") {
|
||||
prefix = "root"
|
||||
}
|
||||
return prefix + arcname
|
||||
}
|
||||
|
||||
func ParseCopyInstruction(spec string) (ans []*CopyInstruction, err error) {
|
||||
args, err := shlex.Split("copy " + spec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
opts, args, err := parse_copy_args(args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
locations := make([]string, 0, len(args))
|
||||
for _, arg := range args {
|
||||
locs, err := resolve_file_spec(arg, opts.Glob)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
locations = append(locations, locs...)
|
||||
}
|
||||
if len(locations) == 0 {
|
||||
return nil, fmt.Errorf("No files to copy specified")
|
||||
}
|
||||
if len(locations) > 1 && opts.Dest != "" {
|
||||
return nil, fmt.Errorf("Specifying a remote location with more than one file is not supported")
|
||||
}
|
||||
home := paths_ctx.HomePath()
|
||||
ans = make([]*CopyInstruction, 0, len(locations))
|
||||
for _, loc := range locations {
|
||||
ci := CopyInstruction{local_path: loc, exclude_patterns: opts.Exclude}
|
||||
if opts.SymlinkStrategy != "preserve" {
|
||||
ci.local_path, err = filepath.EvalSymlinks(loc)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Failed to resolve symlinks in %#v with error: %w", loc, err)
|
||||
}
|
||||
}
|
||||
if opts.SymlinkStrategy == "resolve" {
|
||||
ci.arcname = get_arcname(ci.local_path, opts.Dest, home)
|
||||
} else {
|
||||
ci.arcname = get_arcname(loc, opts.Dest, home)
|
||||
}
|
||||
ans = append(ans, &ci)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
type file_unique_id struct {
|
||||
dev, inode uint64
|
||||
}
|
||||
|
||||
func excluded(pattern, path string) bool {
|
||||
if !strings.ContainsRune(pattern, '/') {
|
||||
path = filepath.Base(path)
|
||||
}
|
||||
if matched, err := doublestar.PathMatch(pattern, path); matched && err == nil {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func get_file_data(callback func(h *tar.Header, data []byte) error, seen map[file_unique_id]string, local_path, arcname string, exclude_patterns []string) error {
|
||||
s, err := os.Lstat(local_path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
u, ok := s.Sys().(unix.Stat_t)
|
||||
cb := func(h *tar.Header, data []byte, arcname string) error {
|
||||
h.Name = arcname
|
||||
if h.Typeflag == tar.TypeDir {
|
||||
h.Name = strings.TrimRight(h.Name, "/") + "/"
|
||||
}
|
||||
h.Size = int64(len(data))
|
||||
h.Mode = int64(s.Mode().Perm())
|
||||
h.ModTime = s.ModTime()
|
||||
h.Format = tar.FormatPAX
|
||||
if ok {
|
||||
h.AccessTime = time.Unix(0, u.Atim.Nano())
|
||||
h.ChangeTime = time.Unix(0, u.Ctim.Nano())
|
||||
}
|
||||
return callback(h, data)
|
||||
}
|
||||
// we only copy regular files, directories and symlinks
|
||||
switch s.Mode().Type() {
|
||||
case fs.ModeSymlink:
|
||||
target, err := os.Readlink(local_path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = cb(&tar.Header{
|
||||
Typeflag: tar.TypeSymlink,
|
||||
Linkname: target,
|
||||
}, nil, arcname)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
case fs.ModeDir:
|
||||
local_path = filepath.Clean(local_path)
|
||||
type entry struct {
|
||||
path, arcname string
|
||||
}
|
||||
stack := []entry{{local_path, arcname}}
|
||||
for len(stack) > 0 {
|
||||
x := stack[0]
|
||||
stack = stack[1:]
|
||||
entries, err := os.ReadDir(x.path)
|
||||
if err != nil {
|
||||
if x.path == local_path {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
err = cb(&tar.Header{Typeflag: tar.TypeDir}, nil, x.arcname)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, e := range entries {
|
||||
entry_path := filepath.Join(x.path, e.Name())
|
||||
aname := path.Join(x.arcname, e.Name())
|
||||
ok := true
|
||||
for _, pat := range exclude_patterns {
|
||||
if excluded(pat, entry_path) {
|
||||
ok = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if e.IsDir() {
|
||||
stack = append(stack, entry{entry_path, aname})
|
||||
} else {
|
||||
err = get_file_data(callback, seen, entry_path, aname, exclude_patterns)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
case 0: // Regular file
|
||||
fid := file_unique_id{dev: uint64(u.Dev), inode: uint64(u.Ino)}
|
||||
if prev, ok := seen[fid]; ok { // Hard link
|
||||
err = cb(&tar.Header{Typeflag: tar.TypeLink, Linkname: prev}, nil, arcname)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
seen[fid] = arcname
|
||||
data, err := os.ReadFile(local_path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = cb(&tar.Header{Typeflag: tar.TypeReg}, data, arcname)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ci *CopyInstruction) get_file_data(callback func(h *tar.Header, data []byte) error, seen map[file_unique_id]string) (err error) {
|
||||
ep := ci.exclude_patterns
|
||||
for _, folder_name := range []string{"__pycache__", ".DS_Store"} {
|
||||
ep = append(ep, "**/"+folder_name, "**/"+folder_name+"/**")
|
||||
}
|
||||
return get_file_data(callback, seen, ci.local_path, ci.arcname, ep)
|
||||
}
|
||||
|
||||
type ConfigSet struct {
|
||||
all_configs []*Config
|
||||
}
|
||||
|
||||
func config_for_hostname(hostname_to_match, username_to_match string, cs *ConfigSet) *Config {
|
||||
matcher := func(q *Config) bool {
|
||||
for _, pat := range strings.Split(q.Hostname, " ") {
|
||||
upat := "*"
|
||||
if strings.Contains(pat, "@") {
|
||||
upat, pat, _ = strings.Cut(pat, "@")
|
||||
}
|
||||
var host_matched, user_matched bool
|
||||
if matched, err := filepath.Match(pat, hostname_to_match); matched && err == nil {
|
||||
host_matched = true
|
||||
}
|
||||
if matched, err := filepath.Match(upat, username_to_match); matched && err == nil {
|
||||
user_matched = true
|
||||
}
|
||||
if host_matched && user_matched {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
for _, c := range utils.Reversed(cs.all_configs) {
|
||||
if matcher(c) {
|
||||
return c
|
||||
}
|
||||
}
|
||||
return cs.all_configs[0]
|
||||
}
|
||||
|
||||
func (self *ConfigSet) line_handler(key, val string) error {
|
||||
c := self.all_configs[len(self.all_configs)-1]
|
||||
if key == "hostname" {
|
||||
c = NewConfig()
|
||||
self.all_configs = append(self.all_configs, c)
|
||||
}
|
||||
return c.Parse(key, val)
|
||||
}
|
||||
|
||||
func load_config(hostname_to_match string, username_to_match string, overrides []string, paths ...string) (*Config, []config.ConfigLine, error) {
|
||||
ans := &ConfigSet{all_configs: []*Config{NewConfig()}}
|
||||
p := config.ConfigParser{LineHandler: ans.line_handler}
|
||||
err := p.LoadConfig("ssh.conf", paths, overrides)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return config_for_hostname(hostname_to_match, username_to_match, ans), p.BadLines(), nil
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
|
||||
|
||||
package ssh
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"kitty/tools/utils"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
)
|
||||
|
||||
var _ = fmt.Print
|
||||
|
||||
func TestSSHConfigParsing(t *testing.T) {
|
||||
tdir := t.TempDir()
|
||||
hostname := "unmatched"
|
||||
username := ""
|
||||
conf := ""
|
||||
for_python := false
|
||||
cf := filepath.Join(tdir, "ssh.conf")
|
||||
rt := func(expected_env ...string) {
|
||||
os.WriteFile(cf, []byte(conf), 0o600)
|
||||
c, bad_lines, err := load_config(hostname, username, nil, cf)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(bad_lines) != 0 {
|
||||
t.Fatalf("Bad config line: %s with error: %s", bad_lines[0].Line, bad_lines[0].Err)
|
||||
}
|
||||
actual := final_env_instructions(for_python, func(key string) (string, bool) {
|
||||
if key == "LOCAL_ENV" {
|
||||
return "LOCAL_VAL", true
|
||||
}
|
||||
return "", false
|
||||
}, c.Env...)
|
||||
if expected_env == nil {
|
||||
expected_env = []string{}
|
||||
}
|
||||
diff := cmp.Diff(expected_env, utils.Splitlines(actual))
|
||||
if diff != "" {
|
||||
t.Fatalf("Unexpected env for\nhostname: %#v\nusername: %#v\nconf: %s\n%s", hostname, username, conf, diff)
|
||||
}
|
||||
}
|
||||
rt()
|
||||
conf = "env a=b"
|
||||
rt(`export 'a'="b"`)
|
||||
conf = "env a=b\nhostname 2\nenv a=c\nenv b=b"
|
||||
rt(`export 'a'="b"`)
|
||||
hostname = "2"
|
||||
rt(`export 'a'="c"`, `export 'b'="b"`)
|
||||
conf = "env a="
|
||||
rt(`export 'a'=""`)
|
||||
conf = "env a"
|
||||
rt(`unset 'a'`)
|
||||
conf = "env a=b\nhostname test@2\nenv a=c\nenv b=b"
|
||||
hostname = "unmatched"
|
||||
rt(`export 'a'="b"`)
|
||||
hostname = "2"
|
||||
rt(`export 'a'="b"`)
|
||||
username = "test"
|
||||
rt(`export 'a'="c"`, `export 'b'="b"`)
|
||||
conf = "env a=b\nhostname 1 2\nenv a=c\nenv b=b"
|
||||
username = ""
|
||||
hostname = "unmatched"
|
||||
rt(`export 'a'="b"`)
|
||||
hostname = "1"
|
||||
rt(`export 'a'="c"`, `export 'b'="b"`)
|
||||
hostname = "2"
|
||||
rt(`export 'a'="c"`, `export 'b'="b"`)
|
||||
for_python = true
|
||||
rt(`export ["a","c",false]`, `export ["b","b",false]`)
|
||||
conf = "env a="
|
||||
rt(`export ["a"]`)
|
||||
conf = "env a"
|
||||
rt(`unset ["a"]`)
|
||||
conf = "env LOCAL_ENV=_kitty_copy_env_var_"
|
||||
rt(`export ["LOCAL_ENV","LOCAL_VAL",false]`)
|
||||
|
||||
ci, err := ParseCopyInstruction("--exclude moose --dest=target " + cf)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
diff := cmp.Diff("home/target", ci[0].arcname)
|
||||
if diff != "" {
|
||||
t.Fatalf("Incorrect arcname:\n%s", diff)
|
||||
}
|
||||
diff = cmp.Diff(cf, ci[0].local_path)
|
||||
if diff != "" {
|
||||
t.Fatalf("Incorrect local_path:\n%s", diff)
|
||||
}
|
||||
diff = cmp.Diff([]string{"moose"}, ci[0].exclude_patterns)
|
||||
if diff != "" {
|
||||
t.Fatalf("Incorrect excludes:\n%s", diff)
|
||||
}
|
||||
ci, err = ParseCopyInstruction("--glob " + filepath.Join(filepath.Dir(cf), "*.conf"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
diff = cmp.Diff(cf, ci[0].local_path)
|
||||
if diff != "" {
|
||||
t.Fatalf("Incorrect local_path:\n%s", diff)
|
||||
}
|
||||
if len(ci) != 1 {
|
||||
t.Fatal(ci)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
|
||||
|
||||
package ssh
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
_ "embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"kitty/tools/utils"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var _ = fmt.Print
|
||||
|
||||
//go:embed data_generated.bin
|
||||
var embedded_data string
|
||||
|
||||
type Entry struct {
|
||||
metadata *tar.Header
|
||||
data []byte
|
||||
}
|
||||
|
||||
type Container map[string]Entry
|
||||
|
||||
var Data = (&utils.Once[Container]{Run: func() Container {
|
||||
tr := tar.NewReader(utils.ReaderForCompressedEmbeddedData(embedded_data))
|
||||
ans := make(Container, 64)
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
data, err := utils.ReadAll(tr, int(hdr.Size))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ans[hdr.Name] = Entry{hdr, data}
|
||||
}
|
||||
return ans
|
||||
}}).Get
|
||||
|
||||
func (self Container) files_matching(prefix string, exclude_patterns ...string) []string {
|
||||
ans := make([]string, 0, len(self))
|
||||
patterns := make([]*regexp.Regexp, len(exclude_patterns))
|
||||
for i, exp := range exclude_patterns {
|
||||
patterns[i] = regexp.MustCompile(exp)
|
||||
}
|
||||
for name := range self {
|
||||
if strings.HasPrefix(name, prefix) {
|
||||
excluded := false
|
||||
for _, pat := range patterns {
|
||||
if matched := pat.FindString(name); matched != "" {
|
||||
excluded = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !excluded {
|
||||
ans = append(ans, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ans
|
||||
}
|
||||
@@ -1,800 +0,0 @@
|
||||
// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
|
||||
|
||||
package ssh
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"kitty"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"kitty/tools/cli"
|
||||
"kitty/tools/themes"
|
||||
"kitty/tools/tty"
|
||||
"kitty/tools/tui"
|
||||
"kitty/tools/tui/loop"
|
||||
"kitty/tools/utils"
|
||||
"kitty/tools/utils/secrets"
|
||||
"kitty/tools/utils/shm"
|
||||
|
||||
"golang.org/x/exp/maps"
|
||||
"golang.org/x/exp/slices"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
var _ = fmt.Print
|
||||
|
||||
func get_destination(hostname string) (username, hostname_for_match string) {
|
||||
u, err := user.Current()
|
||||
if err == nil {
|
||||
username = u.Username
|
||||
}
|
||||
hostname_for_match = hostname
|
||||
if strings.HasPrefix(hostname, "ssh://") {
|
||||
p, err := url.Parse(hostname)
|
||||
if err == nil {
|
||||
hostname_for_match = p.Hostname()
|
||||
if p.User.Username() != "" {
|
||||
username = p.User.Username()
|
||||
}
|
||||
}
|
||||
} else if strings.Contains(hostname, "@") && hostname[0] != '@' {
|
||||
username, hostname_for_match, _ = strings.Cut(hostname, "@")
|
||||
}
|
||||
if strings.Contains(hostname, "@") && hostname[0] != '@' {
|
||||
_, hostname_for_match, _ = strings.Cut(hostname_for_match, "@")
|
||||
}
|
||||
hostname_for_match, _, _ = strings.Cut(hostname_for_match, ":")
|
||||
return
|
||||
}
|
||||
|
||||
func read_data_from_shared_memory(shm_name string) ([]byte, error) {
|
||||
data, err := shm.ReadWithSizeAndUnlink(shm_name, func(s fs.FileInfo) error {
|
||||
if stat, ok := s.Sys().(unix.Stat_t); ok {
|
||||
if os.Getuid() != int(stat.Uid) || os.Getgid() != int(stat.Gid) {
|
||||
return fmt.Errorf("Incorrect owner on SHM file")
|
||||
}
|
||||
}
|
||||
if s.Mode().Perm() != 0o600 {
|
||||
return fmt.Errorf("Incorrect permissions on SHM file")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return data, err
|
||||
}
|
||||
|
||||
func add_cloned_env(val string) (ans map[string]string, err error) {
|
||||
data, err := read_data_from_shared_memory(val)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = json.Unmarshal(data, &ans)
|
||||
return ans, err
|
||||
}
|
||||
|
||||
func parse_kitten_args(found_extra_args []string, username, hostname_for_match string) (overrides []string, literal_env map[string]string, ferr error) {
|
||||
literal_env = make(map[string]string)
|
||||
overrides = make([]string, 0, 4)
|
||||
for i, a := range found_extra_args {
|
||||
if i%2 == 0 {
|
||||
continue
|
||||
}
|
||||
if key, val, found := strings.Cut(a, "="); found {
|
||||
if key == "clone_env" {
|
||||
le, err := add_cloned_env(val)
|
||||
if err != nil {
|
||||
if !errors.Is(err, fs.ErrNotExist) {
|
||||
return nil, nil, ferr
|
||||
}
|
||||
} else if le != nil {
|
||||
literal_env = le
|
||||
}
|
||||
} else if key != "hostname" {
|
||||
overrides = append(overrides, key+"="+val)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(overrides) > 0 {
|
||||
overrides = append([]string{"hostname " + username + "@" + hostname_for_match}, overrides...)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func connection_sharing_args(kitty_pid int) ([]string, error) {
|
||||
rd := utils.RuntimeDir()
|
||||
// Bloody OpenSSH generates a 40 char hash and in creating the socket
|
||||
// appends a 27 char temp suffix to it. Socket max path length is approx
|
||||
// ~104 chars. And on idiotic Apple the path length to the runtime dir
|
||||
// (technically the cache dir since Apple has no runtime dir and thinks it's
|
||||
// a great idea to delete files in /tmp) is ~48 chars.
|
||||
if len(rd) > 35 {
|
||||
idiotic_design := fmt.Sprintf("/tmp/kssh-rdir-%d", os.Geteuid())
|
||||
if err := utils.AtomicCreateSymlink(rd, idiotic_design); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rd = idiotic_design
|
||||
}
|
||||
cp := strings.Replace(kitty.SSHControlMasterTemplate, "{kitty_pid}", strconv.Itoa(kitty_pid), 1)
|
||||
cp = strings.Replace(cp, "{ssh_placeholder}", "%C", 1)
|
||||
return []string{
|
||||
"-o", "ControlMaster=auto",
|
||||
"-o", "ControlPath=" + filepath.Join(rd, cp),
|
||||
"-o", "ControlPersist=yes",
|
||||
"-o", "ServerAliveInterval=60",
|
||||
"-o", "ServerAliveCountMax=5",
|
||||
"-o", "TCPKeepAlive=no",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func set_askpass() (need_to_request_data bool) {
|
||||
need_to_request_data = true
|
||||
sentinel := filepath.Join(utils.CacheDir(), "openssh-is-new-enough-for-askpass")
|
||||
_, err := os.Stat(sentinel)
|
||||
sentinel_exists := err == nil
|
||||
if sentinel_exists || GetSSHVersion().SupportsAskpassRequire() {
|
||||
if !sentinel_exists {
|
||||
os.WriteFile(sentinel, []byte{0}, 0o644)
|
||||
}
|
||||
need_to_request_data = false
|
||||
}
|
||||
exe, err := os.Executable()
|
||||
if err == nil {
|
||||
os.Setenv("SSH_ASKPASS", exe)
|
||||
os.Setenv("KITTY_KITTEN_RUN_MODULE", "ssh_askpass")
|
||||
if !need_to_request_data {
|
||||
os.Setenv("SSH_ASKPASS_REQUIRE", "force")
|
||||
}
|
||||
} else {
|
||||
need_to_request_data = true
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
type connection_data struct {
|
||||
remote_args []string
|
||||
host_opts *Config
|
||||
hostname_for_match string
|
||||
username string
|
||||
echo_on bool
|
||||
request_data bool
|
||||
literal_env map[string]string
|
||||
test_script string
|
||||
dont_create_shm bool
|
||||
|
||||
shm_name string
|
||||
script_type string
|
||||
rcmd []string
|
||||
replacements map[string]string
|
||||
request_id string
|
||||
bootstrap_script string
|
||||
}
|
||||
|
||||
func get_effective_ksi_env_var(x string) string {
|
||||
parts := strings.Split(strings.TrimSpace(strings.ToLower(x)), " ")
|
||||
current := utils.NewSetWithItems(parts...)
|
||||
if current.Has("disabled") {
|
||||
return ""
|
||||
}
|
||||
allowed := utils.NewSetWithItems(kitty.AllowedShellIntegrationValues...)
|
||||
if !current.IsSubsetOf(allowed) {
|
||||
return RelevantKittyOpts().Shell_integration
|
||||
}
|
||||
return x
|
||||
}
|
||||
|
||||
func serialize_env(cd *connection_data, get_local_env func(string) (string, bool)) (string, string) {
|
||||
ksi := ""
|
||||
if cd.host_opts.Shell_integration == "inherited" {
|
||||
ksi = get_effective_ksi_env_var(RelevantKittyOpts().Shell_integration)
|
||||
} else {
|
||||
ksi = get_effective_ksi_env_var(cd.host_opts.Shell_integration)
|
||||
}
|
||||
env := make([]*EnvInstruction, 0, 8)
|
||||
add_env := func(key, val string, fallback ...string) *EnvInstruction {
|
||||
if val == "" && len(fallback) > 0 {
|
||||
val = fallback[0]
|
||||
}
|
||||
if val != "" {
|
||||
env = append(env, &EnvInstruction{key: key, val: val, literal_quote: true})
|
||||
return env[len(env)-1]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
add_non_literal_env := func(key, val string, fallback ...string) *EnvInstruction {
|
||||
ans := add_env(key, val, fallback...)
|
||||
if ans != nil {
|
||||
ans.literal_quote = false
|
||||
}
|
||||
return ans
|
||||
}
|
||||
for k, v := range cd.literal_env {
|
||||
add_env(k, v)
|
||||
}
|
||||
add_env("TERM", os.Getenv("TERM"), RelevantKittyOpts().Term)
|
||||
add_env("COLORTERM", "truecolor")
|
||||
env = append(env, cd.host_opts.Env...)
|
||||
add_env("KITTY_WINDOW_ID", os.Getenv("KITTY_WINDOW_ID"))
|
||||
add_env("WINDOWID", os.Getenv("WINDOWID"))
|
||||
if ksi != "" {
|
||||
add_env("KITTY_SHELL_INTEGRATION", ksi)
|
||||
} else {
|
||||
env = append(env, &EnvInstruction{key: "KITTY_SHELL_INTEGRATION", delete_on_remote: true})
|
||||
}
|
||||
add_non_literal_env("KITTY_SSH_KITTEN_DATA_DIR", cd.host_opts.Remote_dir)
|
||||
add_non_literal_env("KITTY_LOGIN_SHELL", cd.host_opts.Login_shell)
|
||||
add_non_literal_env("KITTY_LOGIN_CWD", cd.host_opts.Cwd)
|
||||
if cd.host_opts.Remote_kitty != Remote_kitty_no {
|
||||
add_env("KITTY_REMOTE", cd.host_opts.Remote_kitty.String())
|
||||
}
|
||||
add_env("KITTY_PUBLIC_KEY", os.Getenv("KITTY_PUBLIC_KEY"))
|
||||
return final_env_instructions(cd.script_type == "py", get_local_env, env...), ksi
|
||||
}
|
||||
|
||||
func make_tarfile(cd *connection_data, get_local_env func(string) (string, bool)) ([]byte, error) {
|
||||
env_script, ksi := serialize_env(cd, get_local_env)
|
||||
w := bytes.Buffer{}
|
||||
w.Grow(64 * 1024)
|
||||
gw, err := gzip.NewWriterLevel(&w, gzip.BestCompression)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tw := tar.NewWriter(gw)
|
||||
rd := strings.TrimRight(cd.host_opts.Remote_dir, "/")
|
||||
seen := make(map[file_unique_id]string, 32)
|
||||
add := func(h *tar.Header, data []byte) (err error) {
|
||||
// some distro's like nix mess with installed file permissions so ensure
|
||||
// files are at least readable and writable by owning user
|
||||
h.Mode |= 0o600
|
||||
err = tw.WriteHeader(h)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if data != nil {
|
||||
_, err := tw.Write(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
for _, ci := range cd.host_opts.Copy {
|
||||
err = ci.get_file_data(add, seen)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
type fe struct {
|
||||
arcname string
|
||||
data []byte
|
||||
}
|
||||
now := time.Now()
|
||||
add_data := func(items ...fe) error {
|
||||
for _, item := range items {
|
||||
err := add(
|
||||
&tar.Header{
|
||||
Typeflag: tar.TypeReg, Name: item.arcname, Format: tar.FormatPAX, Size: int64(len(item.data)),
|
||||
Mode: 0o644, ModTime: now, ChangeTime: now, AccessTime: now,
|
||||
}, item.data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
add_entries := func(prefix string, items ...Entry) error {
|
||||
for _, item := range items {
|
||||
err := add(
|
||||
&tar.Header{
|
||||
Typeflag: item.metadata.Typeflag, Name: path.Join(prefix, path.Base(item.metadata.Name)), Format: tar.FormatPAX,
|
||||
Size: int64(len(item.data)), Mode: item.metadata.Mode, ModTime: item.metadata.ModTime,
|
||||
AccessTime: item.metadata.AccessTime, ChangeTime: item.metadata.ChangeTime,
|
||||
}, item.data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
}
|
||||
add_data(fe{"data.sh", utils.UnsafeStringToBytes(env_script)})
|
||||
if cd.script_type == "sh" {
|
||||
add_data(fe{"bootstrap-utils.sh", Data()[path.Join("shell-integration/ssh/bootstrap-utils.sh")].data})
|
||||
}
|
||||
if ksi != "" {
|
||||
for _, fname := range Data().files_matching(
|
||||
"shell-integration/",
|
||||
"shell-integration/ssh/.+", // bootstrap files are sent as command line args
|
||||
"shell-integration/zsh/kitty.zsh", // backward compat file not needed by ssh kitten
|
||||
) {
|
||||
arcname := path.Join("home/", rd, "/", path.Dir(fname))
|
||||
err = add_entries(arcname, Data()[fname])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
if cd.host_opts.Remote_kitty != Remote_kitty_no {
|
||||
arcname := path.Join("home/", rd, "/kitty")
|
||||
err = add_data(fe{arcname + "/version", utils.UnsafeStringToBytes(kitty.VersionString)})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, x := range []string{"kitty", "kitten"} {
|
||||
err = add_entries(path.Join(arcname, "bin"), Data()[path.Join("shell-integration", "ssh", x)])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
err = add_entries(path.Join("home", ".terminfo"), Data()["terminfo/kitty.terminfo"])
|
||||
if err == nil {
|
||||
err = add_entries(path.Join("home", ".terminfo", "x"), Data()["terminfo/x/xterm-kitty"])
|
||||
}
|
||||
if err == nil {
|
||||
err = tw.Close()
|
||||
if err == nil {
|
||||
err = gw.Close()
|
||||
}
|
||||
}
|
||||
return w.Bytes(), err
|
||||
}
|
||||
|
||||
func prepare_home_command(cd *connection_data) string {
|
||||
is_python := cd.script_type == "py"
|
||||
homevar := ""
|
||||
for _, ei := range cd.host_opts.Env {
|
||||
if ei.key == "HOME" && !ei.delete_on_remote {
|
||||
if ei.copy_from_local {
|
||||
homevar = os.Getenv("HOME")
|
||||
} else {
|
||||
homevar = ei.val
|
||||
}
|
||||
}
|
||||
}
|
||||
export_home_cmd := ""
|
||||
if homevar != "" {
|
||||
if is_python {
|
||||
export_home_cmd = base64.StdEncoding.EncodeToString(utils.UnsafeStringToBytes(homevar))
|
||||
} else {
|
||||
export_home_cmd = fmt.Sprintf("export HOME=%s; cd \"$HOME\"", utils.QuoteStringForSH(homevar))
|
||||
}
|
||||
}
|
||||
return export_home_cmd
|
||||
}
|
||||
|
||||
func prepare_exec_cmd(cd *connection_data) string {
|
||||
// ssh simply concatenates multiple commands using a space see
|
||||
// line 1129 of ssh.c and on the remote side sshd.c runs the
|
||||
// concatenated command as shell -c cmd
|
||||
if cd.script_type == "py" {
|
||||
return base64.RawStdEncoding.EncodeToString(utils.UnsafeStringToBytes(strings.Join(cd.remote_args, " ")))
|
||||
}
|
||||
args := make([]string, len(cd.remote_args))
|
||||
for i, arg := range cd.remote_args {
|
||||
args[i] = strings.ReplaceAll(arg, "'", "'\"'\"'")
|
||||
}
|
||||
return "unset KITTY_SHELL_INTEGRATION; exec \"$login_shell\" -c '" + strings.Join(args, " ") + "'"
|
||||
}
|
||||
|
||||
var data_shm shm.MMap
|
||||
|
||||
func prepare_script(script string, replacements map[string]string) string {
|
||||
if _, found := replacements["EXEC_CMD"]; !found {
|
||||
replacements["EXEC_CMD"] = ""
|
||||
}
|
||||
if _, found := replacements["EXPORT_HOME_CMD"]; !found {
|
||||
replacements["EXPORT_HOME_CMD"] = ""
|
||||
}
|
||||
keys := maps.Keys(replacements)
|
||||
for i, key := range keys {
|
||||
keys[i] = "\\b" + key + "\\b"
|
||||
}
|
||||
pat := regexp.MustCompile(strings.Join(keys, "|"))
|
||||
return pat.ReplaceAllStringFunc(script, func(key string) string { return replacements[key] })
|
||||
}
|
||||
|
||||
func bootstrap_script(cd *connection_data) (err error) {
|
||||
if cd.request_id == "" {
|
||||
cd.request_id = os.Getenv("KITTY_PID") + "-" + os.Getenv("KITTY_WINDOW_ID")
|
||||
}
|
||||
export_home_cmd := prepare_home_command(cd)
|
||||
exec_cmd := ""
|
||||
if len(cd.remote_args) > 0 {
|
||||
exec_cmd = prepare_exec_cmd(cd)
|
||||
}
|
||||
pw, err := secrets.TokenHex()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tfd, err := make_tarfile(cd, os.LookupEnv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data := map[string]string{
|
||||
"tarfile": base64.StdEncoding.EncodeToString(tfd),
|
||||
"pw": pw,
|
||||
"hostname": cd.hostname_for_match, "username": cd.username,
|
||||
}
|
||||
encoded_data, err := json.Marshal(data)
|
||||
if err == nil && !cd.dont_create_shm {
|
||||
data_shm, err = shm.CreateTemp(fmt.Sprintf("kssh-%d-", os.Getpid()), uint64(len(encoded_data)+8))
|
||||
if err == nil {
|
||||
err = shm.WriteWithSize(data_shm, encoded_data, 0)
|
||||
if err == nil {
|
||||
err = data_shm.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !cd.dont_create_shm {
|
||||
cd.shm_name = data_shm.Name()
|
||||
}
|
||||
sensitive_data := map[string]string{"REQUEST_ID": cd.request_id, "DATA_PASSWORD": pw, "PASSWORD_FILENAME": cd.shm_name}
|
||||
replacements := map[string]string{
|
||||
"EXPORT_HOME_CMD": export_home_cmd,
|
||||
"EXEC_CMD": exec_cmd,
|
||||
"TEST_SCRIPT": cd.test_script,
|
||||
}
|
||||
add_bool := func(ok bool, key string) {
|
||||
if ok {
|
||||
replacements[key] = "1"
|
||||
} else {
|
||||
replacements[key] = "0"
|
||||
}
|
||||
}
|
||||
add_bool(cd.request_data, "REQUEST_DATA")
|
||||
add_bool(cd.echo_on, "ECHO_ON")
|
||||
sd := maps.Clone(replacements)
|
||||
if cd.request_data {
|
||||
maps.Copy(sd, sensitive_data)
|
||||
}
|
||||
maps.Copy(replacements, sensitive_data)
|
||||
cd.replacements = replacements
|
||||
cd.bootstrap_script = utils.UnsafeBytesToString(Data()["shell-integration/ssh/bootstrap."+cd.script_type].data)
|
||||
cd.bootstrap_script = prepare_script(cd.bootstrap_script, sd)
|
||||
return err
|
||||
}
|
||||
|
||||
func wrap_bootstrap_script(cd *connection_data) {
|
||||
// sshd will execute the command we pass it by join all command line
|
||||
// arguments with a space and passing it as a single argument to the users
|
||||
// login shell with -c. If the user has a non POSIX login shell it might
|
||||
// have different escaping semantics and syntax, so the command it should
|
||||
// execute has to be as simple as possible, basically of the form
|
||||
// interpreter -c unwrap_script escaped_bootstrap_script
|
||||
// The unwrap_script is responsible for unescaping the bootstrap script and
|
||||
// executing it.
|
||||
encoded_script := ""
|
||||
unwrap_script := ""
|
||||
if cd.script_type == "py" {
|
||||
encoded_script = base64.StdEncoding.EncodeToString(utils.UnsafeStringToBytes(cd.bootstrap_script))
|
||||
unwrap_script = `"import base64, sys; eval(compile(base64.standard_b64decode(sys.argv[-1]), 'bootstrap.py', 'exec'))"`
|
||||
} else {
|
||||
// We cant rely on base64 being available on the remote system, so instead
|
||||
// we quote the bootstrap script by replacing ' and \ with \v and \f
|
||||
// also replacing \n and ! with \r and \b for tcsh
|
||||
// finally surrounding with '
|
||||
encoded_script = "'" + strings.NewReplacer("'", "\v", "\\", "\f", "\n", "\r", "!", "\b").Replace(cd.bootstrap_script) + "'"
|
||||
unwrap_script = `'eval "$(echo "$0" | tr \\\v\\\f\\\r\\\b \\\047\\\134\\\n\\\041)"' `
|
||||
}
|
||||
cd.rcmd = []string{"exec", cd.host_opts.Interpreter, "-c", unwrap_script, encoded_script}
|
||||
}
|
||||
|
||||
func get_remote_command(cd *connection_data) error {
|
||||
interpreter := cd.host_opts.Interpreter
|
||||
q := strings.ToLower(path.Base(interpreter))
|
||||
is_python := strings.Contains(q, "python")
|
||||
cd.script_type = "sh"
|
||||
if is_python {
|
||||
cd.script_type = "py"
|
||||
}
|
||||
err := bootstrap_script(cd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
wrap_bootstrap_script(cd)
|
||||
return nil
|
||||
}
|
||||
|
||||
func drain_potential_tty_garbage(term *tty.Term) {
|
||||
err := term.ApplyOperations(tty.TCSANOW, tty.SetNoEcho)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
canary, err := secrets.TokenBase64()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
dcs, err := tui.DCSToKitty("echo", canary+"\n\r")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = term.WriteAllString(dcs)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
q := utils.UnsafeStringToBytes(canary)
|
||||
data := make([]byte, 0)
|
||||
give_up_at := time.Now().Add(2 * time.Second)
|
||||
buf := make([]byte, 0, 8192)
|
||||
for !bytes.Contains(data, q) {
|
||||
buf = buf[:cap(buf)]
|
||||
timeout := give_up_at.Sub(time.Now())
|
||||
if timeout < 0 {
|
||||
break
|
||||
}
|
||||
n, err := term.ReadWithTimeout(buf, timeout)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
data = append(data, buf[:n]...)
|
||||
}
|
||||
}
|
||||
|
||||
func change_colors(color_scheme string) (ans string, err error) {
|
||||
if color_scheme == "" {
|
||||
return
|
||||
}
|
||||
var theme *themes.Theme
|
||||
if !strings.HasSuffix(color_scheme, ".conf") {
|
||||
cs := os.ExpandEnv(color_scheme)
|
||||
tc, closer, err := themes.LoadThemes(-1)
|
||||
if err != nil && errors.Is(err, themes.ErrNoCacheFound) {
|
||||
tc, closer, err = themes.LoadThemes(time.Hour * 24)
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer closer.Close()
|
||||
theme = tc.ThemeByName(cs)
|
||||
if theme == nil {
|
||||
return "", fmt.Errorf("No theme named %#v found", cs)
|
||||
}
|
||||
} else {
|
||||
theme, err = themes.ThemeFromFile(utils.ResolveConfPath(color_scheme))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
ans, err = theme.AsEscapeCodes()
|
||||
if err == nil {
|
||||
ans = "\033[#P" + ans
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func run_ssh(ssh_args, server_args, found_extra_args []string) (rc int, err error) {
|
||||
go Data()
|
||||
go RelevantKittyOpts()
|
||||
defer func() {
|
||||
if data_shm != nil {
|
||||
data_shm.Close()
|
||||
data_shm.Unlink()
|
||||
}
|
||||
}()
|
||||
cmd := append([]string{SSHExe()}, ssh_args...)
|
||||
cd := connection_data{remote_args: server_args[1:]}
|
||||
hostname := server_args[0]
|
||||
if len(cd.remote_args) == 0 {
|
||||
cmd = append(cmd, "-t")
|
||||
}
|
||||
insertion_point := len(cmd)
|
||||
cmd = append(cmd, "--", hostname)
|
||||
uname, hostname_for_match := get_destination(hostname)
|
||||
overrides, literal_env, err := parse_kitten_args(found_extra_args, uname, hostname_for_match)
|
||||
if err != nil {
|
||||
return 1, err
|
||||
}
|
||||
host_opts, bad_lines, err := load_config(hostname_for_match, uname, overrides)
|
||||
if err != nil {
|
||||
return 1, err
|
||||
}
|
||||
if len(bad_lines) > 0 {
|
||||
for _, x := range bad_lines {
|
||||
fmt.Fprintf(os.Stderr, "Ignoring bad config line: %s:%d with error: %s", filepath.Base(x.Src_file), x.Line_number, x.Err)
|
||||
}
|
||||
}
|
||||
if host_opts.Share_connections {
|
||||
kpid, err := strconv.Atoi(os.Getenv("KITTY_PID"))
|
||||
if err != nil {
|
||||
return 1, fmt.Errorf("Invalid KITTY_PID env var not an integer: %#v", os.Getenv("KITTY_PID"))
|
||||
}
|
||||
cpargs, err := connection_sharing_args(kpid)
|
||||
if err != nil {
|
||||
return 1, err
|
||||
}
|
||||
cmd = slices.Insert(cmd, insertion_point, cpargs...)
|
||||
}
|
||||
use_kitty_askpass := host_opts.Askpass == Askpass_native || (host_opts.Askpass == Askpass_unless_set && os.Getenv("SSH_ASKPASS") == "")
|
||||
need_to_request_data := true
|
||||
if use_kitty_askpass {
|
||||
need_to_request_data = set_askpass()
|
||||
}
|
||||
if need_to_request_data && host_opts.Share_connections {
|
||||
check_cmd := slices.Insert(cmd, 1, "-O", "check")
|
||||
err = exec.Command(check_cmd[0], check_cmd[1:]...).Run()
|
||||
if err == nil {
|
||||
need_to_request_data = false
|
||||
}
|
||||
}
|
||||
term, err := tty.OpenControllingTerm(tty.SetNoEcho)
|
||||
if err != nil {
|
||||
return 1, fmt.Errorf("Failed to open controlling terminal with error: %w", err)
|
||||
}
|
||||
cd.echo_on = term.WasEchoOnOriginally()
|
||||
cd.host_opts, cd.literal_env = host_opts, literal_env
|
||||
cd.request_data = need_to_request_data
|
||||
cd.hostname_for_match, cd.username = hostname_for_match, uname
|
||||
escape_codes_to_set_colors, err := change_colors(cd.host_opts.Color_scheme)
|
||||
if err == nil {
|
||||
err = term.WriteAllString(escape_codes_to_set_colors + loop.SAVE_PRIVATE_MODE_VALUES + loop.HANDLE_TERMIOS_SIGNALS.EscapeCodeToSet())
|
||||
}
|
||||
if err != nil {
|
||||
return 1, err
|
||||
}
|
||||
restore_escape_codes := loop.RESTORE_PRIVATE_MODE_VALUES
|
||||
if escape_codes_to_set_colors != "" {
|
||||
restore_escape_codes += "\x1b[#Q"
|
||||
}
|
||||
defer func() {
|
||||
term.WriteAllString(restore_escape_codes)
|
||||
term.RestoreAndClose()
|
||||
}()
|
||||
err = get_remote_command(&cd)
|
||||
if err != nil {
|
||||
return 1, err
|
||||
}
|
||||
cmd = append(cmd, cd.rcmd...)
|
||||
c := exec.Command(cmd[0], cmd[1:]...)
|
||||
c.Stdin, c.Stdout, c.Stderr = os.Stdin, os.Stdout, os.Stderr
|
||||
err = c.Start()
|
||||
if err != nil {
|
||||
return 1, err
|
||||
}
|
||||
if !cd.request_data {
|
||||
rq := fmt.Sprintf("id=%s:pwfile=%s:pw=%s", cd.replacements["REQUEST_ID"], cd.replacements["PASSWORD_FILENAME"], cd.replacements["DATA_PASSWORD"])
|
||||
err := term.ApplyOperations(tty.TCSANOW, tty.SetNoEcho)
|
||||
if err == nil {
|
||||
var dcs string
|
||||
dcs, err = tui.DCSToKitty("ssh", rq)
|
||||
if err == nil {
|
||||
err = term.WriteAllString(dcs)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
c.Process.Kill()
|
||||
c.Wait()
|
||||
return 1, err
|
||||
}
|
||||
}
|
||||
err = c.Wait()
|
||||
drain_potential_tty_garbage(term)
|
||||
if err != nil {
|
||||
var exit_err *exec.ExitError
|
||||
if errors.As(err, &exit_err) {
|
||||
return exit_err.ExitCode(), nil
|
||||
}
|
||||
return 1, err
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func main(cmd *cli.Command, o *Options, args []string) (rc int, err error) {
|
||||
if len(args) > 0 {
|
||||
switch args[0] {
|
||||
case "use-python":
|
||||
args = args[1:] // backwards compat from when we had a python implementation
|
||||
case "-h", "--help":
|
||||
cmd.ShowHelp()
|
||||
return
|
||||
}
|
||||
}
|
||||
ssh_args, server_args, passthrough, found_extra_args, err := ParseSSHArgs(args, "--kitten")
|
||||
if err != nil {
|
||||
var invargs *ErrInvalidSSHArgs
|
||||
switch {
|
||||
case errors.As(err, &invargs):
|
||||
if invargs.Msg != "" {
|
||||
fmt.Fprintln(os.Stderr, invargs.Msg)
|
||||
}
|
||||
return 1, unix.Exec(SSHExe(), []string{"ssh"}, os.Environ())
|
||||
}
|
||||
return 1, err
|
||||
}
|
||||
if passthrough {
|
||||
if len(found_extra_args) > 0 {
|
||||
return 1, fmt.Errorf("The SSH kitten cannot work with the options: %s", strings.Join(maps.Keys(PassthroughArgs()), " "))
|
||||
}
|
||||
return 1, unix.Exec(SSHExe(), append([]string{"ssh"}, args...), os.Environ())
|
||||
}
|
||||
if os.Getenv("KITTY_WINDOW_ID") == "" || os.Getenv("KITTY_PID") == "" {
|
||||
return 1, fmt.Errorf("The SSH kitten is meant to run inside a kitty window")
|
||||
}
|
||||
if !tty.IsTerminal(os.Stdin.Fd()) {
|
||||
return 1, fmt.Errorf("The SSH kitten is meant for interactive use only, STDIN must be a terminal")
|
||||
}
|
||||
return run_ssh(ssh_args, server_args, found_extra_args)
|
||||
}
|
||||
|
||||
func EntryPoint(parent *cli.Command) {
|
||||
create_cmd(parent, main)
|
||||
}
|
||||
|
||||
func specialize_command(ssh *cli.Command) {
|
||||
ssh.Usage = "arguments for the ssh command"
|
||||
ssh.ShortDescription = "Truly convenient SSH"
|
||||
ssh.HelpText = "The ssh kitten is a thin wrapper around the ssh command. It automatically enables shell integration on the remote host, re-uses existing connections to reduce latency, makes the kitty terminfo database available, etc. It's invocation is identical to the ssh command. For details on its usage, see :doc:`/kittens/ssh`."
|
||||
ssh.IgnoreAllArgs = true
|
||||
ssh.OnlyArgsAllowed = true
|
||||
ssh.ArgCompleter = cli.CompletionForWrapper("ssh")
|
||||
}
|
||||
|
||||
func test_integration_with_python(args []string) (rc int, err error) {
|
||||
f, err := os.CreateTemp("", "*.conf")
|
||||
if err != nil {
|
||||
return 1, err
|
||||
}
|
||||
defer func() {
|
||||
f.Close()
|
||||
os.Remove(f.Name())
|
||||
}()
|
||||
_, err = io.Copy(f, os.Stdin)
|
||||
if err != nil {
|
||||
return 1, err
|
||||
}
|
||||
cd := &connection_data{
|
||||
request_id: "testing", remote_args: []string{},
|
||||
username: "testuser", hostname_for_match: "host.test", request_data: true,
|
||||
test_script: args[0], echo_on: true,
|
||||
}
|
||||
opts, bad_lines, err := load_config(cd.hostname_for_match, cd.username, nil, f.Name())
|
||||
if err == nil {
|
||||
if len(bad_lines) > 0 {
|
||||
return 1, fmt.Errorf("Bad config lines: %s with error: %s", bad_lines[0].Line, bad_lines[0].Err)
|
||||
}
|
||||
cd.host_opts = opts
|
||||
err = get_remote_command(cd)
|
||||
}
|
||||
if err != nil {
|
||||
return 1, err
|
||||
}
|
||||
data, err := json.Marshal(map[string]any{"cmd": cd.rcmd, "shm_name": cd.shm_name})
|
||||
if err == nil {
|
||||
_, err = os.Stdout.Write(data)
|
||||
os.Stdout.Close()
|
||||
}
|
||||
if err != nil {
|
||||
return 1, err
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func TestEntryPoint(root *cli.Command) {
|
||||
root.AddSubCommand(&cli.Command{
|
||||
Name: "ssh",
|
||||
OnlyArgsAllowed: true,
|
||||
Run: func(cmd *cli.Command, args []string) (rc int, err error) {
|
||||
return test_integration_with_python(args)
|
||||
},
|
||||
})
|
||||
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
|
||||
|
||||
package ssh
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"kitty/tools/utils/shm"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
var _ = fmt.Print
|
||||
|
||||
func TestCloneEnv(t *testing.T) {
|
||||
env := map[string]string{"a": "1", "b": "2"}
|
||||
data, err := json.Marshal(env)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mmap, err := shm.CreateTemp("", 128)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer mmap.Unlink()
|
||||
copy(mmap.Slice()[4:], data)
|
||||
binary.BigEndian.PutUint32(mmap.Slice(), uint32(len(data)))
|
||||
mmap.Close()
|
||||
x, err := add_cloned_env(mmap.Name())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
diff := cmp.Diff(env, x)
|
||||
if diff != "" {
|
||||
t.Fatalf("Failed to deserialize env\n%s", diff)
|
||||
}
|
||||
}
|
||||
|
||||
func basic_connection_data(overrides ...string) *connection_data {
|
||||
ans := &connection_data{
|
||||
script_type: "sh", request_id: "123-123", remote_args: []string{},
|
||||
username: "testuser", hostname_for_match: "host.test",
|
||||
dont_create_shm: true,
|
||||
}
|
||||
opts, bad_lines, err := load_config(ans.hostname_for_match, ans.username, overrides)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if len(bad_lines) != 0 {
|
||||
panic(fmt.Sprintf("Bad config lines: %s with error: %s", bad_lines[0].Line, bad_lines[0].Err))
|
||||
}
|
||||
ans.host_opts = opts
|
||||
return ans
|
||||
}
|
||||
|
||||
func TestSSHBootstrapScriptLimit(t *testing.T) {
|
||||
cd := basic_connection_data()
|
||||
err := get_remote_command(cd)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
total := 0
|
||||
for _, x := range cd.rcmd {
|
||||
total += len(x)
|
||||
}
|
||||
if total > 9000 {
|
||||
t.Fatalf("Bootstrap script too large: %d bytes", total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSHTarfile(t *testing.T) {
|
||||
tdir := t.TempDir()
|
||||
cd := basic_connection_data()
|
||||
data, err := make_tarfile(cd, func(key string) (val string, found bool) { return })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmd := exec.Command("tar", "xpzf", "-", "-C", tdir)
|
||||
cmd.Stderr = os.Stderr
|
||||
inp, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = cmd.Start()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = inp.Write(data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
inp.Close()
|
||||
err = cmd.Wait()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
seen := map[string]bool{}
|
||||
err = filepath.WalkDir(tdir, func(name string, d fs.DirEntry, werr error) error {
|
||||
if werr != nil {
|
||||
return werr
|
||||
}
|
||||
rname, werr := filepath.Rel(tdir, name)
|
||||
if werr != nil {
|
||||
return werr
|
||||
}
|
||||
rname = strings.ReplaceAll(rname, "\\", "/")
|
||||
if rname == "." {
|
||||
return nil
|
||||
}
|
||||
fi, werr := d.Info()
|
||||
if werr != nil {
|
||||
return werr
|
||||
}
|
||||
if fi.Mode().Perm()&0o600 == 0 {
|
||||
return fmt.Errorf("%s is not rw for its owner. Actual permissions: %s", rname, fi.Mode().String())
|
||||
}
|
||||
seen[rname] = true
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !seen["data.sh"] {
|
||||
t.Fatalf("data.sh missing")
|
||||
}
|
||||
for _, x := range []string{".terminfo/kitty.terminfo", ".terminfo/x/xterm-kitty"} {
|
||||
if !seen["home/"+x] {
|
||||
t.Fatalf("%s missing", x)
|
||||
}
|
||||
}
|
||||
for _, x := range []string{"shell-integration/bash/kitty.bash", "shell-integration/fish/vendor_completions.d/kitty.fish"} {
|
||||
if !seen[path.Join("home", cd.host_opts.Remote_dir, x)] {
|
||||
t.Fatalf("%s missing", x)
|
||||
}
|
||||
}
|
||||
for _, x := range []string{"kitty", "kitten"} {
|
||||
p := filepath.Join(tdir, "home", cd.host_opts.Remote_dir, "kitty", "bin", x)
|
||||
if err = unix.Access(p, unix.X_OK); err != nil {
|
||||
t.Fatalf("Cannot execute %s with error: %s", x, err)
|
||||
}
|
||||
}
|
||||
if seen[path.Join("home", cd.host_opts.Remote_dir, "shell-integration", "ssh", "kitten")] {
|
||||
t.Fatalf("Contents of shell-integration/ssh not excluded")
|
||||
}
|
||||
}
|
||||
@@ -1,240 +0,0 @@
|
||||
// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
|
||||
|
||||
package ssh
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"kitty"
|
||||
"kitty/tools/config"
|
||||
"kitty/tools/utils"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var _ = fmt.Print
|
||||
|
||||
var SSHExe = (&utils.Once[string]{Run: func() string {
|
||||
return utils.FindExe("ssh")
|
||||
}}).Get
|
||||
|
||||
var SSHOptions = (&utils.Once[map[string]string]{Run: func() (ssh_options map[string]string) {
|
||||
defer func() {
|
||||
if ssh_options == nil {
|
||||
ssh_options = map[string]string{
|
||||
"4": "", "6": "", "A": "", "a": "", "C": "", "f": "", "G": "", "g": "", "K": "", "k": "",
|
||||
"M": "", "N": "", "n": "", "q": "", "s": "", "T": "", "t": "", "V": "", "v": "", "X": "",
|
||||
"x": "", "Y": "", "y": "", "B": "bind_interface", "b": "bind_address", "c": "cipher_spec",
|
||||
"D": "[bind_address:]port", "E": "log_file", "e": "escape_char", "F": "configfile", "I": "pkcs11",
|
||||
"i": "identity_file", "J": "[user@]host[:port]", "L": "address", "l": "login_name", "m": "mac_spec",
|
||||
"O": "ctl_cmd", "o": "option", "p": "port", "Q": "query_option", "R": "address",
|
||||
"S": "ctl_path", "W": "host:port", "w": "local_tun[:remote_tun]",
|
||||
}
|
||||
}
|
||||
}()
|
||||
cmd := exec.Command(SSHExe())
|
||||
stderr, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
return
|
||||
}
|
||||
raw, err := io.ReadAll(stderr)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
text := utils.UnsafeBytesToString(raw)
|
||||
ssh_options = make(map[string]string, 32)
|
||||
for {
|
||||
pos := strings.IndexByte(text, '[')
|
||||
if pos < 0 {
|
||||
break
|
||||
}
|
||||
num := 1
|
||||
epos := pos
|
||||
for num > 0 {
|
||||
epos++
|
||||
switch text[epos] {
|
||||
case '[':
|
||||
num += 1
|
||||
case ']':
|
||||
num -= 1
|
||||
}
|
||||
}
|
||||
q := text[pos+1 : epos]
|
||||
text = text[epos:]
|
||||
if len(q) < 2 || !strings.HasPrefix(q, "-") {
|
||||
continue
|
||||
}
|
||||
opt, desc, found := strings.Cut(q, " ")
|
||||
if found {
|
||||
ssh_options[opt[1:]] = desc
|
||||
} else {
|
||||
for _, ch := range opt[1:] {
|
||||
ssh_options[string(ch)] = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}}).Get
|
||||
|
||||
func GetSSHCLI() (boolean_ssh_args *utils.Set[string], other_ssh_args *utils.Set[string]) {
|
||||
other_ssh_args, boolean_ssh_args = utils.NewSet[string](32), utils.NewSet[string](32)
|
||||
for k, v := range SSHOptions() {
|
||||
k = "-" + k
|
||||
if v == "" {
|
||||
boolean_ssh_args.Add(k)
|
||||
} else {
|
||||
other_ssh_args.Add(k)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func is_extra_arg(arg string, extra_args []string) string {
|
||||
for _, x := range extra_args {
|
||||
if arg == x || strings.HasPrefix(arg, x+"=") {
|
||||
return x
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type ErrInvalidSSHArgs struct {
|
||||
Msg string
|
||||
}
|
||||
|
||||
func (self *ErrInvalidSSHArgs) Error() string {
|
||||
return self.Msg
|
||||
}
|
||||
|
||||
func PassthroughArgs() map[string]bool {
|
||||
return map[string]bool{"-N": true, "-n": true, "-f": true, "-G": true, "-T": true}
|
||||
}
|
||||
|
||||
func ParseSSHArgs(args []string, extra_args ...string) (ssh_args []string, server_args []string, passthrough bool, found_extra_args []string, err error) {
|
||||
if extra_args == nil {
|
||||
extra_args = []string{}
|
||||
}
|
||||
if len(args) == 0 {
|
||||
passthrough = true
|
||||
return
|
||||
}
|
||||
passthrough_args := PassthroughArgs()
|
||||
boolean_ssh_args, other_ssh_args := GetSSHCLI()
|
||||
ssh_args, server_args, found_extra_args = make([]string, 0, 16), make([]string, 0, 16), make([]string, 0, 16)
|
||||
expecting_option_val := false
|
||||
stop_option_processing := false
|
||||
expecting_extra_val := ""
|
||||
for _, argument := range args {
|
||||
if len(server_args) > 1 || stop_option_processing {
|
||||
server_args = append(server_args, argument)
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(argument, "-") && !expecting_option_val {
|
||||
if argument == "--" {
|
||||
stop_option_processing = true
|
||||
continue
|
||||
}
|
||||
if len(extra_args) > 0 {
|
||||
matching_ex := is_extra_arg(argument, extra_args)
|
||||
if matching_ex != "" {
|
||||
_, exval, found := strings.Cut(argument, "=")
|
||||
if found {
|
||||
found_extra_args = append(found_extra_args, matching_ex, exval)
|
||||
} else {
|
||||
expecting_extra_val = matching_ex
|
||||
expecting_option_val = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
// could be a multi-character option
|
||||
all_args := []rune(argument[1:])
|
||||
for i, ch := range all_args {
|
||||
arg := "-" + string(ch)
|
||||
if passthrough_args[arg] {
|
||||
passthrough = true
|
||||
}
|
||||
if boolean_ssh_args.Has(arg) {
|
||||
ssh_args = append(ssh_args, arg)
|
||||
continue
|
||||
}
|
||||
if other_ssh_args.Has(arg) {
|
||||
ssh_args = append(ssh_args, arg)
|
||||
if i+1 < len(all_args) {
|
||||
ssh_args = append(ssh_args, string(all_args[i+1:]))
|
||||
} else {
|
||||
expecting_option_val = true
|
||||
}
|
||||
break
|
||||
}
|
||||
err = &ErrInvalidSSHArgs{Msg: "unknown option -- " + arg[1:]}
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
if expecting_option_val {
|
||||
if expecting_extra_val != "" {
|
||||
found_extra_args = append(found_extra_args, expecting_extra_val, argument)
|
||||
} else {
|
||||
ssh_args = append(ssh_args, argument)
|
||||
}
|
||||
expecting_option_val = false
|
||||
continue
|
||||
}
|
||||
server_args = append(server_args, argument)
|
||||
}
|
||||
if len(server_args) == 0 && !passthrough {
|
||||
err = &ErrInvalidSSHArgs{Msg: ""}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
type SSHVersion struct{ Major, Minor int }
|
||||
|
||||
func (self SSHVersion) SupportsAskpassRequire() bool {
|
||||
return self.Major > 8 || (self.Major == 8 && self.Minor >= 4)
|
||||
}
|
||||
|
||||
var GetSSHVersion = (&utils.Once[SSHVersion]{Run: func() SSHVersion {
|
||||
b, err := exec.Command(SSHExe(), "-V").CombinedOutput()
|
||||
if err != nil {
|
||||
return SSHVersion{}
|
||||
}
|
||||
m := regexp.MustCompile(`OpenSSH_(\d+).(\d+)`).FindSubmatch(b)
|
||||
if len(m) == 3 {
|
||||
maj, _ := strconv.Atoi(utils.UnsafeBytesToString(m[1]))
|
||||
min, _ := strconv.Atoi(utils.UnsafeBytesToString(m[2]))
|
||||
return SSHVersion{Major: maj, Minor: min}
|
||||
}
|
||||
return SSHVersion{}
|
||||
}}).Get
|
||||
|
||||
type KittyOpts struct {
|
||||
Term, Shell_integration string
|
||||
}
|
||||
|
||||
func read_relevant_kitty_opts(path string) KittyOpts {
|
||||
ans := KittyOpts{Term: kitty.KittyConfigDefaults.Term, Shell_integration: kitty.KittyConfigDefaults.Shell_integration}
|
||||
handle_line := func(key, val string) error {
|
||||
switch key {
|
||||
case "term":
|
||||
ans.Term = strings.TrimSpace(val)
|
||||
case "shell_integration":
|
||||
ans.Shell_integration = strings.TrimSpace(val)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
cp := config.ConfigParser{LineHandler: handle_line}
|
||||
cp.ParseFiles(path)
|
||||
return ans
|
||||
}
|
||||
|
||||
var RelevantKittyOpts = (&utils.Once[KittyOpts]{Run: func() KittyOpts {
|
||||
return read_relevant_kitty_opts(filepath.Join(utils.ConfigDir(), "kitty.conf"))
|
||||
}}).Get
|
||||
@@ -1,68 +0,0 @@
|
||||
// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
|
||||
|
||||
package ssh
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"kitty/tools/utils/shlex"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
)
|
||||
|
||||
var _ = fmt.Print
|
||||
|
||||
func TestGetSSHOptions(t *testing.T) {
|
||||
m := SSHOptions()
|
||||
if m["w"] != "local_tun[:remote_tun]" {
|
||||
t.Fatalf("Unexpected set of SSH options: %#v", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSSHArgs(t *testing.T) {
|
||||
split := func(x string) []string {
|
||||
ans, err := shlex.Split(x)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return ans
|
||||
}
|
||||
|
||||
p := func(args, expected_ssh_args, expected_server_args, expected_extra_args string, expected_passthrough bool) {
|
||||
ssh_args, server_args, passthrough, extra_args, err := ParseSSHArgs(split(args), "--kitten")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
check := func(a, b any) {
|
||||
diff := cmp.Diff(a, b)
|
||||
if diff != "" {
|
||||
t.Fatalf("Unexpected value for args: %s\n%s", args, diff)
|
||||
}
|
||||
}
|
||||
check(split(expected_ssh_args), ssh_args)
|
||||
check(split(expected_server_args), server_args)
|
||||
check(split(expected_extra_args), extra_args)
|
||||
check(expected_passthrough, passthrough)
|
||||
}
|
||||
p(`localhost`, ``, `localhost`, ``, false)
|
||||
p(`-- localhost`, ``, `localhost`, ``, false)
|
||||
p(`-46p23 localhost sh -c "a b"`, `-4 -6 -p 23`, `localhost sh -c "a b"`, ``, false)
|
||||
p(`-46p23 -S/moose -W x:6 -- localhost sh -c "a b"`, `-4 -6 -p 23 -S /moose -W x:6`, `localhost sh -c "a b"`, ``, false)
|
||||
p(`--kitten=abc -np23 --kitten xyz host`, `-n -p 23`, `host`, `--kitten abc --kitten xyz`, true)
|
||||
}
|
||||
|
||||
func TestRelevantKittyOpts(t *testing.T) {
|
||||
tdir := t.TempDir()
|
||||
path := filepath.Join(tdir, "kitty.conf")
|
||||
os.WriteFile(path, []byte("term XXX\nshell_integration changed\nterm abcd"), 0o600)
|
||||
rko := read_relevant_kitty_opts(path)
|
||||
if rko.Term != "abcd" {
|
||||
t.Fatalf("Unexpected TERM: %s", RelevantKittyOpts().Term)
|
||||
}
|
||||
if rko.Shell_integration != "changed" {
|
||||
t.Fatalf("Unexpected shell_integration: %s", RelevantKittyOpts().Shell_integration)
|
||||
}
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
|
||||
|
||||
package themes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"kitty/tools/themes"
|
||||
"kitty/tools/tty"
|
||||
"kitty/tools/utils"
|
||||
"kitty/tools/wcswidth"
|
||||
)
|
||||
|
||||
var _ = fmt.Print
|
||||
var DebugPrintln = tty.DebugPrintln
|
||||
|
||||
type ThemesList struct {
|
||||
themes, all_themes *themes.Themes
|
||||
current_search string
|
||||
display_strings []string
|
||||
widths []int
|
||||
max_width, current_idx int
|
||||
}
|
||||
|
||||
func (self *ThemesList) Len() int {
|
||||
if self.themes == nil {
|
||||
return 0
|
||||
}
|
||||
return self.themes.Len()
|
||||
}
|
||||
|
||||
func (self *ThemesList) Next(delta int, allow_wrapping bool) bool {
|
||||
if len(self.display_strings) == 0 {
|
||||
return false
|
||||
}
|
||||
idx := self.current_idx + delta
|
||||
if !allow_wrapping && (idx < 0 || idx > self.Len()) {
|
||||
return false
|
||||
}
|
||||
for idx < 0 {
|
||||
idx += self.Len()
|
||||
}
|
||||
self.current_idx = idx % self.Len()
|
||||
return true
|
||||
}
|
||||
|
||||
func limit_lengths(text string) string {
|
||||
t, x := wcswidth.TruncateToVisualLengthWithWidth(text, 31)
|
||||
if x >= len(text) {
|
||||
return text
|
||||
}
|
||||
return t + "…"
|
||||
}
|
||||
|
||||
func (self *ThemesList) UpdateThemes(themes *themes.Themes) {
|
||||
self.themes, self.all_themes = themes, themes
|
||||
if self.current_search != "" {
|
||||
self.themes = self.all_themes.Copy()
|
||||
self.display_strings = utils.Map(limit_lengths, self.themes.ApplySearch(self.current_search))
|
||||
} else {
|
||||
self.display_strings = utils.Map(limit_lengths, self.themes.Names())
|
||||
}
|
||||
self.widths = utils.Map(wcswidth.Stringwidth, self.display_strings)
|
||||
self.max_width = utils.Max(0, self.widths...)
|
||||
self.current_idx = 0
|
||||
}
|
||||
|
||||
func (self *ThemesList) UpdateSearch(query string) bool {
|
||||
if query == self.current_search || self.all_themes == nil {
|
||||
return false
|
||||
}
|
||||
self.current_search = query
|
||||
self.UpdateThemes(self.all_themes)
|
||||
return true
|
||||
}
|
||||
|
||||
type Line struct {
|
||||
text string
|
||||
width int
|
||||
is_current bool
|
||||
}
|
||||
|
||||
func (self *ThemesList) Lines(num_rows int) []Line {
|
||||
if num_rows < 1 {
|
||||
return nil
|
||||
}
|
||||
ans := make([]Line, 0, len(self.display_strings))
|
||||
before_num := utils.Min(self.current_idx, num_rows-1)
|
||||
start := self.current_idx - before_num
|
||||
for i := start; i < utils.Min(start+num_rows, len(self.display_strings)); i++ {
|
||||
ans = append(ans, Line{self.display_strings[i], self.widths[i], i == self.current_idx})
|
||||
}
|
||||
return ans
|
||||
}
|
||||
|
||||
func (self *ThemesList) CurrentTheme() *themes.Theme {
|
||||
if self.themes == nil {
|
||||
return nil
|
||||
}
|
||||
return self.themes.At(self.current_idx)
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
|
||||
|
||||
package themes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"kitty/tools/cli"
|
||||
"kitty/tools/themes"
|
||||
"kitty/tools/tui/loop"
|
||||
"kitty/tools/utils"
|
||||
)
|
||||
|
||||
var _ = fmt.Print
|
||||
|
||||
func complete_themes(completions *cli.Completions, word string, arg_num int) {
|
||||
themes.CompleteThemes(completions, word, arg_num)
|
||||
}
|
||||
|
||||
func non_interactive(opts *Options, theme_name string) (rc int, err error) {
|
||||
themes, closer, err := themes.LoadThemes(time.Duration(opts.CacheAge * float64(time.Hour*24)))
|
||||
if err != nil {
|
||||
return 1, err
|
||||
}
|
||||
defer closer.Close()
|
||||
theme := themes.ThemeByName(theme_name)
|
||||
if theme == nil {
|
||||
theme_name = strings.ReplaceAll(theme_name, `\`, ``)
|
||||
theme = themes.ThemeByName(theme_name)
|
||||
if theme == nil {
|
||||
return 1, fmt.Errorf("No theme named: %s", theme_name)
|
||||
}
|
||||
}
|
||||
if opts.DumpTheme {
|
||||
code, err := theme.Code()
|
||||
if err != nil {
|
||||
return 1, err
|
||||
}
|
||||
fmt.Println(code)
|
||||
} else {
|
||||
err = theme.SaveInConf(utils.ConfigDir(), opts.ReloadIn, opts.ConfigFileName)
|
||||
if err != nil {
|
||||
return 1, err
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func main(_ *cli.Command, opts *Options, args []string) (rc int, err error) {
|
||||
if len(args) > 1 {
|
||||
args = []string{strings.Join(args, ` `)}
|
||||
}
|
||||
if len(args) == 1 {
|
||||
return non_interactive(opts, args[0])
|
||||
}
|
||||
lp, err := loop.New()
|
||||
if err != nil {
|
||||
return 1, err
|
||||
}
|
||||
cv := utils.NewCachedValues("unicode-input", &CachedData{Category: "All"})
|
||||
h := &handler{lp: lp, opts: opts, cached_data: cv.Load()}
|
||||
defer cv.Save()
|
||||
lp.OnInitialize = func() (string, error) {
|
||||
lp.AllowLineWrapping(false)
|
||||
lp.SetWindowTitle(`Choose a theme for kitty`)
|
||||
h.initialize()
|
||||
return "", nil
|
||||
}
|
||||
lp.OnWakeup = h.on_wakeup
|
||||
lp.OnFinalize = func() string {
|
||||
h.finalize()
|
||||
lp.SetCursorVisible(true)
|
||||
return ``
|
||||
}
|
||||
lp.OnResize = func(_, _ loop.ScreenSize) error {
|
||||
h.draw_screen()
|
||||
return nil
|
||||
}
|
||||
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)
|
||||
}
|
||||
@@ -1,610 +0,0 @@
|
||||
// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
|
||||
|
||||
package themes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"kitty/tools/config"
|
||||
"kitty/tools/themes"
|
||||
"kitty/tools/tui/loop"
|
||||
"kitty/tools/tui/readline"
|
||||
"kitty/tools/utils"
|
||||
"kitty/tools/wcswidth"
|
||||
|
||||
"golang.org/x/exp/maps"
|
||||
"golang.org/x/exp/slices"
|
||||
)
|
||||
|
||||
var _ = fmt.Print
|
||||
|
||||
type State int
|
||||
|
||||
const (
|
||||
FETCHING State = iota
|
||||
BROWSING
|
||||
SEARCHING
|
||||
ACCEPTING
|
||||
)
|
||||
const SEPARATOR = "║"
|
||||
|
||||
type CachedData struct {
|
||||
Recent []string `json:"recent"`
|
||||
Category string `json:"category"`
|
||||
}
|
||||
|
||||
type fetch_data struct {
|
||||
themes *themes.Themes
|
||||
err error
|
||||
closer io.Closer
|
||||
}
|
||||
|
||||
var category_filters = map[string]func(*themes.Theme) bool{
|
||||
"all": func(*themes.Theme) bool { return true },
|
||||
"dark": func(t *themes.Theme) bool { return t.IsDark() },
|
||||
"light": func(t *themes.Theme) bool { return !t.IsDark() },
|
||||
"user": func(t *themes.Theme) bool { return t.IsUserDefined() },
|
||||
}
|
||||
|
||||
func recent_filter(items []string) func(*themes.Theme) bool {
|
||||
allowed := utils.NewSetWithItems(items...)
|
||||
return func(t *themes.Theme) bool {
|
||||
return allowed.Has(t.Name())
|
||||
}
|
||||
}
|
||||
|
||||
type handler struct {
|
||||
lp *loop.Loop
|
||||
opts *Options
|
||||
cached_data *CachedData
|
||||
|
||||
state State
|
||||
fetch_result chan fetch_data
|
||||
all_themes *themes.Themes
|
||||
themes_closer io.Closer
|
||||
themes_list *ThemesList
|
||||
category_filters map[string]func(*themes.Theme) bool
|
||||
colors_set_once bool
|
||||
tabs []string
|
||||
rl *readline.Readline
|
||||
}
|
||||
|
||||
// fetching {{{
|
||||
func (self *handler) fetch_themes() {
|
||||
r := fetch_data{}
|
||||
r.themes, r.closer, r.err = themes.LoadThemes(time.Duration(self.opts.CacheAge * float64(time.Hour*24)))
|
||||
self.lp.WakeupMainThread()
|
||||
self.fetch_result <- r
|
||||
}
|
||||
|
||||
func (self *handler) on_fetching_key_event(ev *loop.KeyEvent) error {
|
||||
if ev.MatchesPressOrRepeat("esc") {
|
||||
self.lp.Quit(0)
|
||||
ev.Handled = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *handler) on_wakeup() error {
|
||||
r := <-self.fetch_result
|
||||
if r.err != nil {
|
||||
return r.err
|
||||
}
|
||||
self.state = BROWSING
|
||||
self.all_themes = r.themes
|
||||
self.themes_closer = r.closer
|
||||
self.redraw_after_category_change()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *handler) draw_fetching_screen() {
|
||||
self.lp.Println("Downloading themes from repository, please wait...")
|
||||
}
|
||||
|
||||
// }}}
|
||||
|
||||
func (self *handler) finalize() {
|
||||
t := self.themes_closer
|
||||
if t != nil {
|
||||
t.Close()
|
||||
self.themes_closer = nil
|
||||
}
|
||||
}
|
||||
|
||||
func (self *handler) initialize() {
|
||||
self.tabs = strings.Split("all dark light recent user", " ")
|
||||
self.rl = readline.New(self.lp, readline.RlInit{DontMarkPrompts: true, Prompt: "/"})
|
||||
self.themes_list = &ThemesList{}
|
||||
self.fetch_result = make(chan fetch_data)
|
||||
self.category_filters = make(map[string]func(*themes.Theme) bool, len(category_filters)+1)
|
||||
maps.Copy(self.category_filters, category_filters)
|
||||
self.category_filters["recent"] = recent_filter(self.cached_data.Recent)
|
||||
go self.fetch_themes()
|
||||
self.draw_screen()
|
||||
}
|
||||
|
||||
func (self *handler) enforce_cursor_state() {
|
||||
self.lp.SetCursorVisible(self.state == FETCHING)
|
||||
}
|
||||
|
||||
func (self *handler) draw_screen() {
|
||||
self.lp.StartAtomicUpdate()
|
||||
defer self.lp.EndAtomicUpdate()
|
||||
self.lp.ClearScreen()
|
||||
self.enforce_cursor_state()
|
||||
switch self.state {
|
||||
case FETCHING:
|
||||
self.draw_fetching_screen()
|
||||
case BROWSING, SEARCHING:
|
||||
self.draw_browsing_screen()
|
||||
case ACCEPTING:
|
||||
self.draw_accepting_screen()
|
||||
}
|
||||
}
|
||||
|
||||
func (self *handler) current_category() string {
|
||||
ans := self.cached_data.Category
|
||||
if self.category_filters[ans] == nil {
|
||||
ans = "all"
|
||||
}
|
||||
return ans
|
||||
}
|
||||
|
||||
func (self *handler) set_current_category(category string) {
|
||||
if self.category_filters[category] == nil {
|
||||
category = "all"
|
||||
}
|
||||
self.cached_data.Category = category
|
||||
}
|
||||
|
||||
func ReadKittyColorSettings() map[string]string {
|
||||
settings := make(map[string]string, 512)
|
||||
handle_line := func(key, val string) error {
|
||||
if themes.AllColorSettingNames[key] {
|
||||
settings[key] = val
|
||||
}
|
||||
return nil
|
||||
}
|
||||
cp := config.ConfigParser{LineHandler: handle_line}
|
||||
cp.ParseFiles(filepath.Join(utils.ConfigDir(), "kitty.conf"))
|
||||
return settings
|
||||
}
|
||||
|
||||
func (self *handler) set_colors_to_current_theme() bool {
|
||||
if self.themes_list == nil && self.colors_set_once {
|
||||
return false
|
||||
}
|
||||
self.colors_set_once = true
|
||||
if self.themes_list != nil {
|
||||
t := self.themes_list.CurrentTheme()
|
||||
if t != nil {
|
||||
raw, err := t.AsEscapeCodes()
|
||||
if err == nil {
|
||||
self.lp.QueueWriteString(raw)
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
self.lp.QueueWriteString(themes.ColorSettingsAsEscapeCodes(ReadKittyColorSettings()))
|
||||
return true
|
||||
}
|
||||
|
||||
func (self *handler) redraw_after_category_change() {
|
||||
self.themes_list.UpdateThemes(self.all_themes.Filtered(self.category_filters[self.current_category()]))
|
||||
self.set_colors_to_current_theme()
|
||||
self.draw_screen()
|
||||
}
|
||||
|
||||
func (self *handler) on_key_event(ev *loop.KeyEvent) error {
|
||||
switch self.state {
|
||||
case FETCHING:
|
||||
return self.on_fetching_key_event(ev)
|
||||
case BROWSING:
|
||||
return self.on_browsing_key_event(ev)
|
||||
case SEARCHING:
|
||||
return self.on_searching_key_event(ev)
|
||||
case ACCEPTING:
|
||||
return self.on_accepting_key_event(ev)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// browsing ... {{{
|
||||
|
||||
func (self *handler) next_category(delta int) {
|
||||
idx := slices.Index(self.tabs, self.current_category()) + delta + len(self.tabs)
|
||||
self.set_current_category(self.tabs[idx%len(self.tabs)])
|
||||
self.redraw_after_category_change()
|
||||
}
|
||||
|
||||
func (self *handler) next(delta int, allow_wrapping bool) {
|
||||
if self.themes_list.Next(delta, allow_wrapping) {
|
||||
self.set_colors_to_current_theme()
|
||||
self.draw_screen()
|
||||
} else {
|
||||
self.lp.Beep()
|
||||
}
|
||||
}
|
||||
|
||||
func (self *handler) on_browsing_key_event(ev *loop.KeyEvent) error {
|
||||
if ev.MatchesPressOrRepeat("esc") || ev.MatchesPressOrRepeat("q") {
|
||||
self.lp.Quit(0)
|
||||
ev.Handled = true
|
||||
return nil
|
||||
}
|
||||
for _, cat := range self.tabs {
|
||||
if ev.MatchesPressOrRepeat(cat[0:1]) || ev.MatchesPressOrRepeat("alt+"+cat[0:1]) {
|
||||
ev.Handled = true
|
||||
if cat != self.current_category() {
|
||||
self.set_current_category(cat)
|
||||
self.redraw_after_category_change()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
if ev.MatchesPressOrRepeat("left") || ev.MatchesPressOrRepeat("shift+tab") {
|
||||
self.next_category(-1)
|
||||
ev.Handled = true
|
||||
return nil
|
||||
}
|
||||
if ev.MatchesPressOrRepeat("right") || ev.MatchesPressOrRepeat("tab") {
|
||||
self.next_category(1)
|
||||
ev.Handled = true
|
||||
return nil
|
||||
}
|
||||
if ev.MatchesPressOrRepeat("j") || ev.MatchesPressOrRepeat("down") {
|
||||
self.next(1, true)
|
||||
ev.Handled = true
|
||||
return nil
|
||||
}
|
||||
if ev.MatchesPressOrRepeat("k") || ev.MatchesPressOrRepeat("up") {
|
||||
self.next(-1, true)
|
||||
ev.Handled = true
|
||||
return nil
|
||||
}
|
||||
if ev.MatchesPressOrRepeat("page_down") {
|
||||
ev.Handled = true
|
||||
sz, err := self.lp.ScreenSize()
|
||||
if err == nil {
|
||||
self.next(int(sz.HeightCells)-3, false)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if ev.MatchesPressOrRepeat("page_up") {
|
||||
ev.Handled = true
|
||||
sz, err := self.lp.ScreenSize()
|
||||
if err == nil {
|
||||
self.next(3-int(sz.HeightCells), false)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if ev.MatchesPressOrRepeat("s") || ev.MatchesPressOrRepeat("/") {
|
||||
ev.Handled = true
|
||||
self.start_search()
|
||||
return nil
|
||||
}
|
||||
if ev.MatchesPressOrRepeat("c") || ev.MatchesPressOrRepeat("enter") {
|
||||
ev.Handled = true
|
||||
if self.themes_list == nil || self.themes_list.Len() == 0 {
|
||||
self.lp.Beep()
|
||||
} else {
|
||||
self.state = ACCEPTING
|
||||
self.draw_screen()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *handler) start_search() {
|
||||
self.state = SEARCHING
|
||||
self.rl.SetText(self.themes_list.current_search)
|
||||
self.draw_screen()
|
||||
}
|
||||
|
||||
func (self *handler) draw_browsing_screen() {
|
||||
self.draw_tab_bar()
|
||||
sz, err := self.lp.ScreenSize()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
num_rows := int(sz.HeightCells) - 2
|
||||
mw := self.themes_list.max_width + 1
|
||||
green_fg, _, _ := strings.Cut(self.lp.SprintStyled("fg=green", "|"), "|")
|
||||
for _, l := range self.themes_list.Lines(num_rows) {
|
||||
line := l.text
|
||||
if l.is_current {
|
||||
line = strings.ReplaceAll(line, themes.MARK_AFTER, green_fg)
|
||||
self.lp.PrintStyled("fg=green", ">")
|
||||
self.lp.PrintStyled("fg=green bold", line)
|
||||
} else {
|
||||
self.lp.PrintStyled("fg=green", " ")
|
||||
self.lp.QueueWriteString(line)
|
||||
}
|
||||
self.lp.MoveCursorHorizontally(mw - l.width)
|
||||
self.lp.Println(SEPARATOR)
|
||||
}
|
||||
if self.themes_list != nil && self.themes_list.Len() > 0 {
|
||||
self.draw_theme_demo()
|
||||
}
|
||||
if self.state == BROWSING {
|
||||
self.draw_bottom_bar()
|
||||
} else {
|
||||
self.draw_search_bar()
|
||||
}
|
||||
}
|
||||
|
||||
func (self *handler) draw_bottom_bar() {
|
||||
sz, err := self.lp.ScreenSize()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
self.lp.MoveCursorTo(1, int(sz.HeightCells))
|
||||
self.lp.PrintStyled("reverse", strings.Repeat(" ", int(sz.WidthCells)))
|
||||
self.lp.QueueWriteString("\r")
|
||||
|
||||
draw_tab := func(t, sc string) {
|
||||
text := self.mark_shortcut(utils.Capitalize(t), sc)
|
||||
self.lp.PrintStyled("reverse", " "+text+" ")
|
||||
}
|
||||
draw_tab("search (/)", "s")
|
||||
draw_tab("accept (⏎)", "c")
|
||||
self.lp.QueueWriteString("\x1b[m")
|
||||
}
|
||||
|
||||
func (self *handler) draw_search_bar() {
|
||||
sz, err := self.lp.ScreenSize()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
self.lp.MoveCursorTo(1, int(sz.HeightCells))
|
||||
self.lp.ClearToEndOfLine()
|
||||
self.rl.RedrawNonAtomic()
|
||||
}
|
||||
|
||||
func (self *handler) mark_shortcut(text, acc string) string {
|
||||
acc_idx := strings.Index(strings.ToLower(text), strings.ToLower(acc))
|
||||
return text[:acc_idx] + self.lp.SprintStyled("underline bold", text[acc_idx:acc_idx+1]) + text[acc_idx+1:]
|
||||
}
|
||||
|
||||
func (self *handler) draw_tab_bar() {
|
||||
sz, err := self.lp.ScreenSize()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
self.lp.PrintStyled("reverse", strings.Repeat(` `, int(sz.WidthCells)))
|
||||
self.lp.QueueWriteString("\r")
|
||||
cc := self.current_category()
|
||||
draw_tab := func(text, name, acc string) {
|
||||
is_active := name == cc
|
||||
if is_active {
|
||||
text := self.lp.SprintStyled("italic", fmt.Sprintf("%s #%d", text, self.themes_list.Len()))
|
||||
self.lp.Printf(" %s ", text)
|
||||
} else {
|
||||
text = self.mark_shortcut(text, acc)
|
||||
self.lp.PrintStyled("reverse", " "+text+" ")
|
||||
}
|
||||
}
|
||||
for _, title := range self.tabs {
|
||||
draw_tab(utils.Capitalize(title), title, string([]rune(title)[0]))
|
||||
}
|
||||
self.lp.Println("\x1b[m")
|
||||
}
|
||||
|
||||
func center_string(x string, width int) string {
|
||||
l := wcswidth.Stringwidth(x)
|
||||
spaces := int(float64(width-l) / 2)
|
||||
return strings.Repeat(" ", spaces) + x + strings.Repeat(" ", width-(spaces+l))
|
||||
}
|
||||
|
||||
func (self *handler) draw_theme_demo() {
|
||||
ssz, err := self.lp.ScreenSize()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
theme := self.themes_list.CurrentTheme()
|
||||
if theme == nil {
|
||||
return
|
||||
}
|
||||
xstart := self.themes_list.max_width + 3
|
||||
sz := int(ssz.WidthCells) - xstart
|
||||
if sz < 20 {
|
||||
return
|
||||
}
|
||||
sz--
|
||||
y := 0
|
||||
colors := strings.Split(`black red green yellow blue magenta cyan white`, ` `)
|
||||
trunc := sz/8 - 1
|
||||
pat := regexp.MustCompile(`\s+`)
|
||||
|
||||
next_line := func() {
|
||||
self.lp.QueueWriteString("\r")
|
||||
y++
|
||||
self.lp.MoveCursorTo(xstart, y+1)
|
||||
self.lp.QueueWriteString(SEPARATOR + " ")
|
||||
}
|
||||
|
||||
write_para := func(text string) {
|
||||
text = pat.ReplaceAllLiteralString(text, " ")
|
||||
for text != "" {
|
||||
t, sp := wcswidth.TruncateToVisualLengthWithWidth(text, sz)
|
||||
self.lp.QueueWriteString(t)
|
||||
next_line()
|
||||
text = text[sp:]
|
||||
}
|
||||
}
|
||||
|
||||
write_colors := func(bg string) {
|
||||
for _, intense := range []bool{false, true} {
|
||||
buf := strings.Builder{}
|
||||
buf.Grow(1024)
|
||||
for _, c := range colors {
|
||||
s := c
|
||||
if intense {
|
||||
s = "bright-" + s
|
||||
}
|
||||
if len(c) > trunc {
|
||||
c = c[:trunc]
|
||||
}
|
||||
buf.WriteString(self.lp.SprintStyled("fg="+c, c))
|
||||
buf.WriteString(" ")
|
||||
}
|
||||
text := strings.TrimSpace(buf.String())
|
||||
if bg == "" {
|
||||
self.lp.QueueWriteString(text)
|
||||
} else {
|
||||
s := bg
|
||||
if intense {
|
||||
s = "bright-" + s
|
||||
}
|
||||
self.lp.PrintStyled("bg="+s, text)
|
||||
}
|
||||
next_line()
|
||||
}
|
||||
next_line()
|
||||
}
|
||||
self.lp.MoveCursorTo(1, 1)
|
||||
next_line()
|
||||
self.lp.PrintStyled("fg=green bold", center_string(theme.Name(), sz))
|
||||
next_line()
|
||||
if theme.Author() != "" {
|
||||
self.lp.PrintStyled("italic", center_string(theme.Author(), sz))
|
||||
next_line()
|
||||
}
|
||||
if theme.Blurb() != "" {
|
||||
next_line()
|
||||
write_para(theme.Blurb())
|
||||
next_line()
|
||||
}
|
||||
write_colors("")
|
||||
for _, bg := range colors {
|
||||
write_colors(bg)
|
||||
}
|
||||
}
|
||||
|
||||
// }}}
|
||||
|
||||
// accepting {{{
|
||||
|
||||
func (self *handler) on_accepting_key_event(ev *loop.KeyEvent) error {
|
||||
if ev.MatchesPressOrRepeat("q") || ev.MatchesPressOrRepeat("esc") {
|
||||
ev.Handled = true
|
||||
self.lp.Quit(0)
|
||||
return nil
|
||||
}
|
||||
if ev.MatchesPressOrRepeat("a") {
|
||||
ev.Handled = true
|
||||
self.state = BROWSING
|
||||
self.draw_screen()
|
||||
return nil
|
||||
}
|
||||
if ev.MatchesPressOrRepeat("p") {
|
||||
ev.Handled = true
|
||||
self.themes_list.CurrentTheme().SaveInDir(utils.ConfigDir())
|
||||
self.update_recent()
|
||||
self.lp.Quit(0)
|
||||
return nil
|
||||
}
|
||||
if ev.MatchesPressOrRepeat("m") {
|
||||
ev.Handled = true
|
||||
self.themes_list.CurrentTheme().SaveInConf(utils.ConfigDir(), self.opts.ReloadIn, self.opts.ConfigFileName)
|
||||
self.update_recent()
|
||||
self.lp.Quit(0)
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *handler) update_recent() {
|
||||
if self.themes_list != nil {
|
||||
recent := slices.Clone(self.cached_data.Recent)
|
||||
name := self.themes_list.CurrentTheme().Name()
|
||||
recent = utils.Remove(recent, name)
|
||||
recent = append([]string{name}, recent...)
|
||||
self.cached_data.Recent = recent[:20]
|
||||
}
|
||||
}
|
||||
|
||||
func (self *handler) draw_accepting_screen() {
|
||||
name := self.themes_list.CurrentTheme().Name()
|
||||
name = self.lp.SprintStyled("fg=green bold", name)
|
||||
kc := self.lp.SprintStyled("italic", self.opts.ConfigFileName)
|
||||
|
||||
ac := func(x string) string {
|
||||
return self.lp.SprintStyled("fg=red", x)
|
||||
}
|
||||
self.lp.AllowLineWrapping(true)
|
||||
defer self.lp.AllowLineWrapping(false)
|
||||
self.lp.Printf(`You have chosen the %s theme`, name)
|
||||
self.lp.Println()
|
||||
self.lp.Println()
|
||||
self.lp.Println(`What would you like to do?`)
|
||||
self.lp.Println()
|
||||
self.lp.Printf(` %sodify %s to load %s`, ac("M"), kc, name)
|
||||
self.lp.Println()
|
||||
self.lp.Println()
|
||||
self.lp.Printf(` %slace the theme file in %s but do not modify %s`, ac("P"), utils.ConfigDir(), kc)
|
||||
self.lp.Println()
|
||||
self.lp.Println()
|
||||
self.lp.Printf(` %sbort and return to list of themes`, ac("A"))
|
||||
self.lp.Println()
|
||||
self.lp.Println()
|
||||
self.lp.Printf(` %suit`, ac("Q"))
|
||||
self.lp.Println()
|
||||
}
|
||||
|
||||
// }}}
|
||||
|
||||
// searching {{{
|
||||
|
||||
func (self *handler) update_search() {
|
||||
text := self.rl.AllText()
|
||||
if self.themes_list.UpdateSearch(text) {
|
||||
self.set_colors_to_current_theme()
|
||||
self.draw_screen()
|
||||
} else {
|
||||
self.draw_search_bar()
|
||||
}
|
||||
}
|
||||
|
||||
func (self *handler) on_text(text string, a, b bool) error {
|
||||
if self.state == SEARCHING {
|
||||
err := self.rl.OnText(text, a, b)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
self.update_search()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *handler) on_searching_key_event(ev *loop.KeyEvent) error {
|
||||
if ev.MatchesPressOrRepeat("enter") {
|
||||
ev.Handled = true
|
||||
self.state = BROWSING
|
||||
self.draw_bottom_bar()
|
||||
return nil
|
||||
}
|
||||
if ev.MatchesPressOrRepeat("esc") {
|
||||
ev.Handled = true
|
||||
self.state = BROWSING
|
||||
self.themes_list.UpdateSearch("")
|
||||
self.set_colors_to_current_theme()
|
||||
self.draw_screen()
|
||||
return nil
|
||||
}
|
||||
err := self.rl.OnKeyEvent(ev)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ev.Handled {
|
||||
self.update_search()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// }}}
|
||||
@@ -5,19 +5,19 @@ package tool
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"kitty/kittens/ask"
|
||||
"kitty/kittens/clipboard"
|
||||
"kitty/kittens/diff"
|
||||
"kitty/kittens/hints"
|
||||
"kitty/kittens/hyperlinked_grep"
|
||||
"kitty/kittens/icat"
|
||||
"kitty/kittens/ssh"
|
||||
"kitty/kittens/themes"
|
||||
"kitty/kittens/unicode_input"
|
||||
"kitty/tools/cli"
|
||||
"kitty/tools/cmd/ask"
|
||||
"kitty/tools/cmd/at"
|
||||
"kitty/tools/cmd/clipboard"
|
||||
"kitty/tools/cmd/diff"
|
||||
"kitty/tools/cmd/edit_in_kitty"
|
||||
"kitty/tools/cmd/hints"
|
||||
"kitty/tools/cmd/hyperlinked_grep"
|
||||
"kitty/tools/cmd/icat"
|
||||
"kitty/tools/cmd/pytest"
|
||||
"kitty/tools/cmd/ssh"
|
||||
"kitty/tools/cmd/themes"
|
||||
"kitty/tools/cmd/unicode_input"
|
||||
"kitty/tools/cmd/update_self"
|
||||
"kitty/tools/tui"
|
||||
)
|
||||
|
||||
@@ -1,633 +0,0 @@
|
||||
// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
|
||||
|
||||
package unicode_input
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"kitty/tools/cli"
|
||||
"kitty/tools/tui"
|
||||
"kitty/tools/tui/loop"
|
||||
"kitty/tools/tui/readline"
|
||||
"kitty/tools/unicode_names"
|
||||
"kitty/tools/utils"
|
||||
"kitty/tools/utils/style"
|
||||
"kitty/tools/wcswidth"
|
||||
|
||||
"golang.org/x/exp/slices"
|
||||
)
|
||||
|
||||
var _ = fmt.Print
|
||||
|
||||
const INDEX_CHAR string = "."
|
||||
const INDEX_BASE = 36
|
||||
const InvalidChar rune = unicode.MaxRune + 1
|
||||
const default_set_of_symbols string = `
|
||||
‘’“”‹›«»‚„ 😀😛😇😈😉😍😎😮👍👎 —–§¶†‡©®™ →⇒•·°±−×÷¼½½¾
|
||||
…µ¢£€¿¡¨´¸ˆ˜ ÀÁÂÃÄÅÆÇÈÉÊË ÌÍÎÏÐÑÒÓÔÕÖØ ŒŠÙÚÛÜÝŸÞßàá âãäåæçèéêëìí
|
||||
îïðñòóôõöøœš ùúûüýÿþªºαΩ∞
|
||||
`
|
||||
|
||||
var DEFAULT_SET []rune
|
||||
var EMOTICONS_SET []rune
|
||||
|
||||
const DEFAULT_MODE string = "HEX"
|
||||
|
||||
func build_sets() {
|
||||
DEFAULT_SET = make([]rune, 0, len(default_set_of_symbols))
|
||||
for _, ch := range default_set_of_symbols {
|
||||
if !unicode.IsSpace(ch) {
|
||||
DEFAULT_SET = append(DEFAULT_SET, ch)
|
||||
}
|
||||
}
|
||||
EMOTICONS_SET = make([]rune, 0, 0x1f64f-0x1f600+1)
|
||||
for i := 0x1f600; i <= 0x1f64f; i++ {
|
||||
DEFAULT_SET = append(DEFAULT_SET, rune(i))
|
||||
}
|
||||
}
|
||||
|
||||
func codepoint_ok(code rune) bool {
|
||||
return !(code <= 32 || code == 127 || (128 <= code && code <= 159) || (0xd800 <= code && code <= 0xdbff) || (0xDC00 <= code && code <= 0xDFFF) || code > unicode.MaxRune)
|
||||
}
|
||||
|
||||
func parse_favorites(raw string) (ans []rune) {
|
||||
ans = make([]rune, 0, 128)
|
||||
for _, line := range utils.Splitlines(raw) {
|
||||
line = strings.TrimSpace(line)
|
||||
if len(line) == 0 || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
idx := strings.Index(line, "#")
|
||||
if idx > -1 {
|
||||
line = line[:idx]
|
||||
}
|
||||
code_text, _, _ := strings.Cut(line, " ")
|
||||
code, err := strconv.ParseUint(code_text, 16, 32)
|
||||
if err == nil && codepoint_ok(rune(code)) {
|
||||
ans = append(ans, rune(code))
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func serialize_favorites(favs []rune) string {
|
||||
b := strings.Builder{}
|
||||
b.Grow(8192)
|
||||
b.WriteString(`# Favorite characters for unicode input
|
||||
# Enter the hex code for each favorite character on a new line. Blank lines are
|
||||
# ignored and anything after a # is considered a comment.
|
||||
|
||||
`)
|
||||
for _, ch := range favs {
|
||||
b.WriteString(fmt.Sprintf("%x # %s %s\n", ch, string(ch), unicode_names.NameForCodePoint(ch)))
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
var loaded_favorites []rune
|
||||
|
||||
func favorites_path() string {
|
||||
return filepath.Join(utils.ConfigDir(), "unicode-input-favorites.conf")
|
||||
}
|
||||
|
||||
func load_favorites(refresh bool) []rune {
|
||||
if refresh || loaded_favorites == nil {
|
||||
raw, err := os.ReadFile(favorites_path())
|
||||
if err == nil {
|
||||
loaded_favorites = parse_favorites(utils.UnsafeBytesToString(raw))
|
||||
} else {
|
||||
loaded_favorites = DEFAULT_SET
|
||||
}
|
||||
}
|
||||
return loaded_favorites
|
||||
}
|
||||
|
||||
type CachedData struct {
|
||||
Recent []rune `json:"recent,omitempty"`
|
||||
Mode string `json:"mode,omitempty"`
|
||||
}
|
||||
|
||||
var cached_data *CachedData
|
||||
|
||||
type Mode int
|
||||
|
||||
const (
|
||||
HEX Mode = iota
|
||||
NAME
|
||||
EMOTICONS
|
||||
FAVORITES
|
||||
)
|
||||
|
||||
type ModeData struct {
|
||||
mode Mode
|
||||
key string
|
||||
title string
|
||||
}
|
||||
|
||||
var all_modes [4]ModeData
|
||||
|
||||
type checkpoints_key struct {
|
||||
mode Mode
|
||||
text string
|
||||
codepoints []rune
|
||||
index_word int
|
||||
}
|
||||
|
||||
func (self *checkpoints_key) clear() {
|
||||
*self = checkpoints_key{}
|
||||
}
|
||||
|
||||
func (self *checkpoints_key) is_equal(other checkpoints_key) bool {
|
||||
return self.mode == other.mode && self.text == other.text && slices.Equal(self.codepoints, other.codepoints) && self.index_word == other.index_word
|
||||
}
|
||||
|
||||
type handler struct {
|
||||
mode Mode
|
||||
recent []rune
|
||||
current_char rune
|
||||
err error
|
||||
lp *loop.Loop
|
||||
ctx style.Context
|
||||
rl *readline.Readline
|
||||
choice_line string
|
||||
emoji_variation string
|
||||
checkpoints_key checkpoints_key
|
||||
table table
|
||||
|
||||
current_tab_formatter, tab_bar_formatter, chosen_formatter, chosen_name_formatter, dim_formatter func(...any) string
|
||||
}
|
||||
|
||||
func (self *handler) initialize() {
|
||||
self.ctx.AllowEscapeCodes = true
|
||||
self.checkpoints_key.index_word = -1
|
||||
self.table.initialize(self.emoji_variation, self.ctx)
|
||||
self.lp.SetWindowTitle("Unicode input")
|
||||
self.current_char = InvalidChar
|
||||
self.current_tab_formatter = self.ctx.SprintFunc("reverse=false bold=true")
|
||||
self.tab_bar_formatter = self.ctx.SprintFunc("reverse=true")
|
||||
self.chosen_formatter = self.ctx.SprintFunc("fg=green")
|
||||
self.chosen_name_formatter = self.ctx.SprintFunc("italic=true dim=true")
|
||||
self.dim_formatter = self.ctx.SprintFunc("dim=true")
|
||||
self.rl = readline.New(self.lp, readline.RlInit{Prompt: "> ", DontMarkPrompts: true})
|
||||
self.rl.Start()
|
||||
self.refresh()
|
||||
}
|
||||
|
||||
func (self *handler) finalize() string {
|
||||
self.rl.End()
|
||||
self.rl.Shutdown()
|
||||
return ""
|
||||
}
|
||||
|
||||
func (self *handler) resolved_char() string {
|
||||
if self.current_char == InvalidChar {
|
||||
return ""
|
||||
}
|
||||
return resolved_char(self.current_char, self.emoji_variation)
|
||||
}
|
||||
|
||||
func is_index(word string) bool {
|
||||
if !strings.HasPrefix(word, INDEX_CHAR) {
|
||||
return false
|
||||
}
|
||||
word = strings.TrimLeft(word, INDEX_CHAR)
|
||||
_, err := strconv.ParseUint(word, INDEX_BASE, 32)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (self *handler) update_codepoints() {
|
||||
var index_word uint64
|
||||
var q checkpoints_key
|
||||
q.mode = self.mode
|
||||
q.index_word = -1
|
||||
switch self.mode {
|
||||
case HEX:
|
||||
q.codepoints = self.recent
|
||||
if len(q.codepoints) == 0 {
|
||||
q.codepoints = DEFAULT_SET
|
||||
}
|
||||
case EMOTICONS:
|
||||
q.codepoints = EMOTICONS_SET
|
||||
case FAVORITES:
|
||||
q.codepoints = load_favorites(false)
|
||||
case NAME:
|
||||
q.text = self.rl.AllText()
|
||||
if !q.is_equal(self.checkpoints_key) {
|
||||
words := strings.Split(q.text, " ")
|
||||
words = utils.RemoveAll(words, INDEX_CHAR)
|
||||
if len(words) > 1 {
|
||||
for i, w := range words {
|
||||
if i > 0 && is_index(w) {
|
||||
iw := words[i]
|
||||
words = words[:i]
|
||||
index_word, _ = strconv.ParseUint(strings.TrimLeft(iw, INDEX_CHAR), INDEX_BASE, 32)
|
||||
q.index_word = int(index_word)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
query := strings.Join(words, " ")
|
||||
if len(query) > 1 {
|
||||
words = words[1:]
|
||||
q.codepoints = unicode_names.CodePointsForQuery(query)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !q.is_equal(self.checkpoints_key) {
|
||||
self.checkpoints_key = q
|
||||
self.table.set_codepoints(q.codepoints, self.mode, q.index_word)
|
||||
}
|
||||
}
|
||||
|
||||
func (self *handler) update_current_char() {
|
||||
self.update_codepoints()
|
||||
self.current_char = InvalidChar
|
||||
text := self.rl.AllText()
|
||||
switch self.mode {
|
||||
case HEX:
|
||||
if strings.HasPrefix(text, INDEX_CHAR) {
|
||||
if len(text) > 1 {
|
||||
self.current_char = self.table.codepoint_at_hint(text[1:])
|
||||
}
|
||||
} else if len(text) > 0 {
|
||||
code, err := strconv.ParseUint(text, 16, 32)
|
||||
if err == nil && code <= unicode.MaxRune {
|
||||
self.current_char = rune(code)
|
||||
}
|
||||
}
|
||||
case NAME:
|
||||
cc := self.table.current_codepoint()
|
||||
if cc > 0 && cc <= unicode.MaxRune {
|
||||
self.current_char = rune(cc)
|
||||
}
|
||||
default:
|
||||
if len(text) > 0 {
|
||||
self.current_char = self.table.codepoint_at_hint(strings.TrimLeft(text, INDEX_CHAR))
|
||||
}
|
||||
}
|
||||
if !codepoint_ok(self.current_char) {
|
||||
self.current_char = InvalidChar
|
||||
}
|
||||
}
|
||||
|
||||
func (self *handler) update_prompt() {
|
||||
self.update_current_char()
|
||||
ch := "??"
|
||||
color := "red"
|
||||
self.choice_line = ""
|
||||
if self.current_char != InvalidChar {
|
||||
ch, color = self.resolved_char(), "green"
|
||||
self.choice_line = fmt.Sprintf(
|
||||
"Chosen: %s U+%x %s", self.chosen_formatter(ch), self.current_char,
|
||||
self.chosen_name_formatter(title(unicode_names.NameForCodePoint(self.current_char))))
|
||||
}
|
||||
prompt := fmt.Sprintf("%s> ", self.ctx.SprintFunc("fg="+color)(ch))
|
||||
self.rl.SetPrompt(prompt)
|
||||
}
|
||||
|
||||
func (self *handler) draw_title_bar() {
|
||||
self.lp.AllowLineWrapping(false)
|
||||
entries := make([]string, 0, len(all_modes))
|
||||
for _, md := range all_modes {
|
||||
entry := fmt.Sprintf(" %s (%s) ", md.title, md.key)
|
||||
if md.mode == self.mode {
|
||||
entry = self.current_tab_formatter(entry)
|
||||
}
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
sz, _ := self.lp.ScreenSize()
|
||||
text := fmt.Sprintf("Search by:%s", strings.Join(entries, ""))
|
||||
extra := int(sz.WidthCells) - wcswidth.Stringwidth(text)
|
||||
if extra > 0 {
|
||||
text += strings.Repeat(" ", extra)
|
||||
}
|
||||
self.lp.Println(self.tab_bar_formatter(text))
|
||||
}
|
||||
|
||||
func (self *handler) draw_screen() {
|
||||
self.lp.StartAtomicUpdate()
|
||||
defer self.lp.EndAtomicUpdate()
|
||||
self.lp.ClearScreen()
|
||||
self.draw_title_bar()
|
||||
|
||||
y := 1
|
||||
writeln := func(text ...any) {
|
||||
self.lp.Println(text...)
|
||||
y += 1
|
||||
}
|
||||
switch self.mode {
|
||||
case NAME:
|
||||
writeln("Enter words from the name of the character")
|
||||
case HEX:
|
||||
writeln("Enter the hex code for the character")
|
||||
default:
|
||||
writeln("Enter the index for the character you want from the list below")
|
||||
}
|
||||
self.rl.RedrawNonAtomic()
|
||||
self.lp.AllowLineWrapping(false)
|
||||
self.lp.SaveCursorPosition()
|
||||
defer self.lp.RestoreCursorPosition()
|
||||
writeln()
|
||||
writeln(self.choice_line)
|
||||
sz, _ := self.lp.ScreenSize()
|
||||
|
||||
write_help := func(x string) {
|
||||
lines := style.WrapTextAsLines(x, "", int(sz.WidthCells)-1)
|
||||
for _, line := range lines {
|
||||
if line != "" {
|
||||
writeln(self.dim_formatter(line))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch self.mode {
|
||||
case HEX:
|
||||
write_help(fmt.Sprintf("Type %s followed by the index for the recent entries below", INDEX_CHAR))
|
||||
case NAME:
|
||||
write_help(fmt.Sprintf("Use Tab or arrow keys to choose a character. Type space and %s to select by index", INDEX_CHAR))
|
||||
case FAVORITES:
|
||||
write_help("Press F12 to edit the list of favorites")
|
||||
}
|
||||
q := self.table.layout(int(sz.HeightCells)-y, int(sz.WidthCells))
|
||||
if q != "" {
|
||||
self.lp.QueueWriteString(q)
|
||||
}
|
||||
}
|
||||
|
||||
func (self *handler) on_text(text string, from_key_event, in_bracketed_paste bool) error {
|
||||
err := self.rl.OnText(text, from_key_event, in_bracketed_paste)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
self.refresh()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *handler) switch_mode(mode Mode) {
|
||||
if self.mode != mode {
|
||||
self.mode = mode
|
||||
self.rl.ResetText()
|
||||
self.current_char = InvalidChar
|
||||
self.choice_line = ""
|
||||
}
|
||||
}
|
||||
|
||||
func (self *handler) handle_hex_key_event(event *loop.KeyEvent) {
|
||||
text := self.rl.AllText()
|
||||
val, err := strconv.ParseUint(text, 16, 32)
|
||||
new_val := -1
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if event.MatchesPressOrRepeat("tab") {
|
||||
new_val = int(val) + 10
|
||||
} else if event.MatchesPressOrRepeat("up") {
|
||||
new_val = int(val) + 1
|
||||
} else if event.MatchesPressOrRepeat("down") {
|
||||
new_val = utils.Max(32, int(val)-1)
|
||||
}
|
||||
if new_val > -1 {
|
||||
event.Handled = true
|
||||
self.rl.SetText(fmt.Sprintf("%x", new_val))
|
||||
}
|
||||
}
|
||||
|
||||
func (self *handler) handle_name_key_event(event *loop.KeyEvent) {
|
||||
if event.MatchesPressOrRepeat("shift+tab") || event.MatchesPressOrRepeat("left") {
|
||||
event.Handled = true
|
||||
self.table.move_current(0, -1)
|
||||
} else if event.MatchesPressOrRepeat("tab") || event.MatchesPressOrRepeat("right") {
|
||||
event.Handled = true
|
||||
self.table.move_current(0, 1)
|
||||
} else if event.MatchesPressOrRepeat("up") {
|
||||
event.Handled = true
|
||||
self.table.move_current(-1, 0)
|
||||
} else if event.MatchesPressOrRepeat("down") {
|
||||
event.Handled = true
|
||||
self.table.move_current(1, 0)
|
||||
}
|
||||
}
|
||||
|
||||
func (self *handler) handle_emoticons_key_event(event *loop.KeyEvent) {
|
||||
}
|
||||
|
||||
func (self *handler) handle_favorites_key_event(event *loop.KeyEvent) {
|
||||
if event.MatchesPressOrRepeat("f12") {
|
||||
event.Handled = true
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
self.err = err
|
||||
self.lp.Quit(1)
|
||||
return
|
||||
}
|
||||
raw := serialize_favorites(load_favorites(false))
|
||||
fp := favorites_path()
|
||||
err = os.MkdirAll(filepath.Dir(fp), 0o755)
|
||||
if err != nil {
|
||||
self.err = fmt.Errorf("Failed to create config directory to store favorites in: %w", err)
|
||||
self.lp.Quit(1)
|
||||
return
|
||||
}
|
||||
err = utils.AtomicUpdateFile(fp, utils.UnsafeStringToBytes(raw), 0o600)
|
||||
if err != nil {
|
||||
self.err = fmt.Errorf("Failed to write to favorites file %s with error: %w", fp, err)
|
||||
self.lp.Quit(1)
|
||||
return
|
||||
}
|
||||
resume, err := self.lp.Suspend()
|
||||
if err != nil {
|
||||
self.err = err
|
||||
self.lp.Quit(1)
|
||||
return
|
||||
}
|
||||
defer resume()
|
||||
cmd := exec.Command(exe, "edit-in-kitty", "--type=overlay", fp)
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
err = cmd.Run()
|
||||
if err == nil {
|
||||
load_favorites(true)
|
||||
} else {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
fmt.Fprintln(os.Stderr, "Failed to run edit-in-kitty, favorites have not been changed. Press Enter to continue.")
|
||||
var ln string
|
||||
fmt.Scanln(&ln)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (self *handler) next_mode(delta int) {
|
||||
for num, md := range all_modes {
|
||||
if md.mode == self.mode {
|
||||
idx := (num + delta + len(all_modes)) % len(all_modes)
|
||||
md = all_modes[idx]
|
||||
self.switch_mode(md.mode)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (self *handler) on_key_event(event *loop.KeyEvent) (err error) {
|
||||
if event.MatchesPressOrRepeat("esc") || event.MatchesPressOrRepeat("ctrl+c") {
|
||||
return fmt.Errorf("Canceled by user")
|
||||
}
|
||||
if event.MatchesPressOrRepeat("f1") || event.MatchesPressOrRepeat("ctrl+1") {
|
||||
event.Handled = true
|
||||
self.switch_mode(HEX)
|
||||
} else if event.MatchesPressOrRepeat("f2") || event.MatchesPressOrRepeat("ctrl+2") {
|
||||
event.Handled = true
|
||||
self.switch_mode(NAME)
|
||||
} else if event.MatchesPressOrRepeat("f3") || event.MatchesPressOrRepeat("ctrl+3") {
|
||||
event.Handled = true
|
||||
self.switch_mode(EMOTICONS)
|
||||
} else if event.MatchesPressOrRepeat("f4") || event.MatchesPressOrRepeat("ctrl+4") {
|
||||
event.Handled = true
|
||||
self.switch_mode(FAVORITES)
|
||||
} else if event.MatchesPressOrRepeat("tab") || event.MatchesPressOrRepeat("ctrl+]") {
|
||||
event.Handled = true
|
||||
self.next_mode(1)
|
||||
} else if event.MatchesPressOrRepeat("shift+tab") || event.MatchesPressOrRepeat("ctrl+[") {
|
||||
event.Handled = true
|
||||
self.next_mode(-1)
|
||||
}
|
||||
if !event.Handled {
|
||||
switch self.mode {
|
||||
case HEX:
|
||||
self.handle_hex_key_event(event)
|
||||
case NAME:
|
||||
self.handle_name_key_event(event)
|
||||
case EMOTICONS:
|
||||
self.handle_emoticons_key_event(event)
|
||||
case FAVORITES:
|
||||
self.handle_favorites_key_event(event)
|
||||
}
|
||||
}
|
||||
if !event.Handled {
|
||||
err = self.rl.OnKeyEvent(event)
|
||||
if err != nil {
|
||||
if err == readline.ErrAcceptInput {
|
||||
self.refresh()
|
||||
self.lp.Quit(0)
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
if event.Handled {
|
||||
self.refresh()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (self *handler) refresh() {
|
||||
self.update_prompt()
|
||||
self.draw_screen()
|
||||
}
|
||||
|
||||
func run_loop(opts *Options) (lp *loop.Loop, err error) {
|
||||
output := tui.KittenOutputSerializer()
|
||||
lp, err = loop.New()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
cv := utils.NewCachedValues("unicode-input", &CachedData{Recent: DEFAULT_SET, Mode: DEFAULT_MODE})
|
||||
cached_data = cv.Load()
|
||||
defer cv.Save()
|
||||
|
||||
h := handler{recent: cached_data.Recent, lp: lp, emoji_variation: opts.EmojiVariation}
|
||||
switch cached_data.Mode {
|
||||
case "HEX":
|
||||
h.mode = HEX
|
||||
case "NAME":
|
||||
h.mode = NAME
|
||||
case "EMOTICONS":
|
||||
h.mode = EMOTICONS
|
||||
case "FAVORITES":
|
||||
h.mode = FAVORITES
|
||||
}
|
||||
all_modes[0] = ModeData{mode: HEX, title: "Code", key: "F1"}
|
||||
all_modes[1] = ModeData{mode: NAME, title: "Name", key: "F2"}
|
||||
all_modes[2] = ModeData{mode: EMOTICONS, title: "Emoticons", key: "F3"}
|
||||
all_modes[3] = ModeData{mode: FAVORITES, title: "Favorites", key: "F4"}
|
||||
|
||||
lp.OnInitialize = func() (string, error) {
|
||||
h.initialize()
|
||||
lp.SendOverlayReady()
|
||||
return "", nil
|
||||
}
|
||||
|
||||
lp.OnResize = func(old_size, new_size loop.ScreenSize) error {
|
||||
h.refresh()
|
||||
return nil
|
||||
}
|
||||
|
||||
lp.OnResumeFromStop = func() error {
|
||||
h.refresh()
|
||||
return nil
|
||||
}
|
||||
|
||||
lp.OnText = h.on_text
|
||||
lp.OnFinalize = h.finalize
|
||||
lp.OnKeyEvent = h.on_key_event
|
||||
|
||||
err = lp.Run()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if h.err == nil {
|
||||
switch h.mode {
|
||||
case HEX:
|
||||
cached_data.Mode = "HEX"
|
||||
case NAME:
|
||||
cached_data.Mode = "NAME"
|
||||
case EMOTICONS:
|
||||
cached_data.Mode = "EMOTICONS"
|
||||
case FAVORITES:
|
||||
cached_data.Mode = "FAVORITES"
|
||||
}
|
||||
if h.current_char != InvalidChar {
|
||||
cached_data.Recent = h.recent
|
||||
idx := slices.Index(cached_data.Recent, h.current_char)
|
||||
if idx > -1 {
|
||||
cached_data.Recent = slices.Delete(cached_data.Recent, idx, idx+1)
|
||||
}
|
||||
cached_data.Recent = slices.Insert(cached_data.Recent, 0, h.current_char)[:len(DEFAULT_SET)]
|
||||
ans := h.resolved_char()
|
||||
o, err := output(ans)
|
||||
if err != nil {
|
||||
return lp, err
|
||||
}
|
||||
fmt.Println(o)
|
||||
}
|
||||
}
|
||||
err = h.err
|
||||
return
|
||||
}
|
||||
|
||||
func main(cmd *cli.Command, o *Options, args []string) (rc int, err error) {
|
||||
go unicode_names.Initialize() // start parsing name data in the background
|
||||
build_sets()
|
||||
lp, err := run_loop(o)
|
||||
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)
|
||||
}
|
||||
@@ -1,246 +0,0 @@
|
||||
// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
|
||||
|
||||
package unicode_input
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"kitty/tools/unicode_names"
|
||||
"kitty/tools/utils"
|
||||
"kitty/tools/utils/style"
|
||||
"kitty/tools/wcswidth"
|
||||
|
||||
"golang.org/x/exp/slices"
|
||||
)
|
||||
|
||||
var _ = fmt.Print
|
||||
|
||||
func resolved_char(ch rune, emoji_variation string) string {
|
||||
ans := string(ch)
|
||||
if wcswidth.IsEmojiPresentationBase(ch) {
|
||||
switch emoji_variation {
|
||||
case "text":
|
||||
ans += "\ufe0e"
|
||||
case "graphic":
|
||||
ans += "\ufe0f"
|
||||
}
|
||||
}
|
||||
return ans
|
||||
|
||||
}
|
||||
|
||||
func decode_hint(text string) int {
|
||||
x, err := strconv.ParseUint(text, INDEX_BASE, 32)
|
||||
if err != nil {
|
||||
return -1
|
||||
}
|
||||
return int(x)
|
||||
}
|
||||
|
||||
func encode_hint(num int) string {
|
||||
return strconv.FormatUint(uint64(num), INDEX_BASE)
|
||||
}
|
||||
|
||||
func ljust(s string, sz int) string {
|
||||
x := wcswidth.Stringwidth(s)
|
||||
if x < sz {
|
||||
s += strings.Repeat(" ", sz-x)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
type table struct {
|
||||
emoji_variation string
|
||||
layout_dirty bool
|
||||
last_rows, last_cols int
|
||||
codepoints []rune
|
||||
current_idx, scroll_rows int
|
||||
text string
|
||||
num_cols, num_rows int
|
||||
mode Mode
|
||||
|
||||
green, reversed, intense_gray func(...any) string
|
||||
}
|
||||
|
||||
func (self *table) initialize(emoji_variation string, ctx style.Context) {
|
||||
self.emoji_variation = emoji_variation
|
||||
self.layout_dirty = true
|
||||
self.last_cols, self.last_rows = -1, -1
|
||||
self.green = ctx.SprintFunc("fg=green")
|
||||
self.reversed = ctx.SprintFunc("reverse=true")
|
||||
self.intense_gray = ctx.SprintFunc("fg=intense-gray")
|
||||
}
|
||||
|
||||
func (self *table) current_codepoint() rune {
|
||||
if len(self.codepoints) > 0 {
|
||||
return self.codepoints[self.current_idx]
|
||||
}
|
||||
return InvalidChar
|
||||
}
|
||||
|
||||
func (self *table) set_codepoints(codepoints []rune, mode Mode, current_idx int) {
|
||||
self.codepoints = codepoints
|
||||
if self.codepoints != nil {
|
||||
slices.Sort(self.codepoints)
|
||||
}
|
||||
self.mode = mode
|
||||
self.layout_dirty = true
|
||||
if current_idx > -1 && current_idx < len(self.codepoints) {
|
||||
self.current_idx = current_idx
|
||||
}
|
||||
if self.current_idx >= len(self.codepoints) {
|
||||
self.current_idx = 0
|
||||
}
|
||||
self.scroll_rows = 0
|
||||
}
|
||||
|
||||
func (self *table) codepoint_at_hint(hint string) rune {
|
||||
idx := decode_hint(hint)
|
||||
if idx >= 0 && idx < len(self.codepoints) {
|
||||
return self.codepoints[idx]
|
||||
}
|
||||
return InvalidChar
|
||||
}
|
||||
|
||||
type cell_data struct {
|
||||
idx, ch, desc string
|
||||
}
|
||||
|
||||
func title(x string) string {
|
||||
if len(x) > 1 {
|
||||
x = strings.ToUpper(x[:1]) + x[1:]
|
||||
}
|
||||
return x
|
||||
}
|
||||
|
||||
func (self *table) layout(rows, cols int) string {
|
||||
if !self.layout_dirty && self.last_cols == cols && self.last_rows == rows {
|
||||
return self.text
|
||||
}
|
||||
self.last_cols, self.last_rows = cols, rows
|
||||
self.layout_dirty = false
|
||||
var as_parts func(int, rune) cell_data
|
||||
var cell func(int, cell_data)
|
||||
var idx_size, space_for_desc int
|
||||
output := strings.Builder{}
|
||||
output.Grow(4096)
|
||||
switch self.mode {
|
||||
case NAME:
|
||||
as_parts = func(i int, codepoint rune) cell_data {
|
||||
return cell_data{idx: ljust(encode_hint(i), idx_size), ch: resolved_char(codepoint, self.emoji_variation), desc: title(unicode_names.NameForCodePoint(codepoint))}
|
||||
}
|
||||
|
||||
cell = func(i int, cd cell_data) {
|
||||
is_current := i == self.current_idx
|
||||
text := self.green(cd.idx) + " " + cd.ch + " "
|
||||
w := wcswidth.Stringwidth(cd.ch)
|
||||
if w < 2 {
|
||||
text += strings.Repeat(" ", (2 - w))
|
||||
}
|
||||
desc_width := wcswidth.Stringwidth(cd.desc)
|
||||
if desc_width > space_for_desc {
|
||||
text += cd.desc[:space_for_desc-1] + "…"
|
||||
} else {
|
||||
text += cd.desc
|
||||
extra := space_for_desc - desc_width
|
||||
if extra > 0 {
|
||||
text += strings.Repeat(" ", extra)
|
||||
}
|
||||
}
|
||||
if is_current {
|
||||
text = self.reversed(text)
|
||||
}
|
||||
output.WriteString(text)
|
||||
}
|
||||
default:
|
||||
as_parts = func(i int, codepoint rune) cell_data {
|
||||
return cell_data{idx: ljust(encode_hint(i), idx_size), ch: resolved_char(codepoint, self.emoji_variation)}
|
||||
}
|
||||
|
||||
cell = func(i int, cd cell_data) {
|
||||
output.WriteString(self.green(cd.idx))
|
||||
output.WriteString(" ")
|
||||
output.WriteString(self.intense_gray(cd.ch))
|
||||
w := wcswidth.Stringwidth(cd.ch)
|
||||
if w < 2 {
|
||||
output.WriteString(strings.Repeat(" ", (2 - w)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
num := len(self.codepoints)
|
||||
if num < 1 {
|
||||
self.text = ""
|
||||
self.num_cols = 0
|
||||
self.num_rows = 0
|
||||
return self.text
|
||||
}
|
||||
idx_size = len(encode_hint(num - 1))
|
||||
|
||||
parts := make([]cell_data, len(self.codepoints))
|
||||
for i, ch := range self.codepoints {
|
||||
parts[i] = as_parts(i, ch)
|
||||
}
|
||||
longest := 0
|
||||
switch self.mode {
|
||||
case NAME:
|
||||
for _, p := range parts {
|
||||
longest = utils.Max(longest, idx_size+2+len(p.desc)+2)
|
||||
}
|
||||
default:
|
||||
longest = idx_size + 3
|
||||
}
|
||||
col_width := longest + 2
|
||||
col_width = utils.Min(col_width, 40)
|
||||
space_for_desc = col_width - 2 - idx_size - 4
|
||||
self.num_cols = utils.Max(cols/col_width, 1)
|
||||
self.num_rows = rows
|
||||
rows_left := rows
|
||||
skip_scroll := self.scroll_rows * self.num_cols
|
||||
|
||||
for i, cd := range parts {
|
||||
if skip_scroll > 0 {
|
||||
skip_scroll -= 1
|
||||
continue
|
||||
}
|
||||
cell(i, cd)
|
||||
output.WriteString(" ")
|
||||
if i > 0 && (i+1)%self.num_cols == 0 {
|
||||
rows_left -= 1
|
||||
if rows_left == 0 {
|
||||
break
|
||||
}
|
||||
output.WriteString("\r\n")
|
||||
}
|
||||
}
|
||||
|
||||
self.text = output.String()
|
||||
return self.text
|
||||
}
|
||||
|
||||
func (self *table) move_current(rows, cols int) {
|
||||
if len(self.codepoints) == 0 {
|
||||
return
|
||||
}
|
||||
if cols != 0 {
|
||||
self.current_idx = (self.current_idx + len(self.codepoints) + cols) % len(self.codepoints)
|
||||
self.layout_dirty = true
|
||||
}
|
||||
if rows != 0 {
|
||||
amt := rows * self.num_cols
|
||||
self.current_idx += amt
|
||||
self.current_idx = utils.Max(0, utils.Min(self.current_idx, len(self.codepoints)-1))
|
||||
self.layout_dirty = true
|
||||
}
|
||||
first_visible := self.scroll_rows * self.num_cols
|
||||
last_visible := first_visible + ((self.num_cols * self.num_rows) - 1)
|
||||
scroll_amount := self.num_rows
|
||||
if self.current_idx < first_visible {
|
||||
self.scroll_rows = utils.Max(self.scroll_rows-scroll_amount, 0)
|
||||
}
|
||||
if self.current_idx > last_visible {
|
||||
self.scroll_rows += scroll_amount
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user