Repository: gokcehan/lf
Pull request #1594 (2024-02-05 02:37:16)

Export mode for use in scripting keybindings
Related:

- #1579 
- #1507

For certain keybindings, it can be useful to modify the behavior based on what mode `lf` is currently in. Right now there is no way to track the current mode except for using user-defined variables, which is more complex and error-prone.

Examples:

Using `filter` to select and open a file:

```shell
set incfilter

map f filter

cmap <enter> &{{
    # select and open file
    if [ "$lf_mode" = "filter" ]; then
        lf -remote "send $id :cmd-enter; setfilter; open"
    else
        lf -remote "send $id cmd-enter"
    fi
}}

cmap <a-n> &{{
    # go to next file
    if [ "$lf_mode" = "filter" ]; then
        lf -remote "send $id down"
    fi
}}

cmap <a-p> &{{
    # go to previous file
    if [ "$lf_mode" = "filter" ]; then
        lf -remote "send $id up"
    fi
}}
```

Expand partially-typed commands before executing them (e.g. type `:del` as a shorthand for `:delete`):

```shell
cmap <enter> &{{
    if [ "$lf_mode" = "command" ]; then
        lf -remote "send $id complete-cmd"
    else
        lf -remote "send $id cmd-enter"
    fi
}}

cmd complete-cmd :{{
    # position cursor at the end of the first word
    cmd-home
    cmd-word

    # perform tab completion before running the command
    cmd-complete
    cmd-enter
}}
```


Patch:
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index e720b8f..dac133f 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -27,7 +27,6 @@ Adding a new option usually requires the following steps:
 - Add default option value to `init` function in `opts.go`
 - Add option evaluation logic to `setExpr.eval` in `eval.go`
 - Implement the option somewhere in the code
-- Add option name to `gOptWords` in `complete.go` for tab completion
 - Add option name and its default value to `Quick Reference` and `Options` sections in `doc.md`
 - Run `gen/doc-with-docker.sh` to update the documentation
 - Commit your changes and send a pull request
diff --git a/app.go b/app.go
index 8f204ba..5b7e344 100644
--- a/app.go
+++ b/app.go
@@ -500,6 +500,7 @@ func (app *app) runCmdSync(cmd *exec.Cmd, pause_after bool) {
 func (app *app) runShell(s string, args []string, prefix string) {
 	app.nav.exportFiles()
 	app.ui.exportSizes()
+	app.ui.exportMode()
 	exportOpts()
 
 	gState.mutex.Lock()
diff --git a/complete.go b/complete.go
index e0055da..cd962a5 100644
--- a/complete.go
+++ b/complete.go
@@ -4,6 +4,7 @@ import (
 	"log"
 	"os"
 	"path/filepath"
+	"reflect"
 	"sort"
 	"strings"
 )
@@ -109,146 +110,45 @@ var (
 		"cmd-lowercase-word",
 	}
 
-	gOptWords = []string{
-		"anchorfind",
-		"noanchorfind",
-		"anchorfind!",
-		"autoquit",
-		"noautoquit",
-		"autoquit!",
-		"borderfmt",
-		"copyfmt",
-		"cursoractivefmt",
-		"cursorparentfmt",
-		"cursorpreviewfmt",
-		"cutfmt",
-		"hidecursorinactive",
-		"nohidecursorinactive",
-		"hidecursorinactive!",
-		"dircache",
-		"nodircache",
-		"dircache!",
-		"dircounts",
-		"nodircounts",
-		"dircounts!",
-		"dirfirst",
-		"nodirfirst",
-		"dirfirst!",
-		"dironly",
-		"nodironly",
-		"dironly!",
-		"dirpreviews",
-		"nodirpreviews",
-		"dirpreviews!",
-		"drawbox",
-		"nodrawbox",
-		"drawbox!",
-		"dupfilefmt",
-		"globsearch",
-		"noglobsearch",
-		"globsearch!",
-		"hidden",
-		"nohidden",
-		"hidden!",
-		"history",
-		"nohistory",
-		"history!",
-		"icons",
-		"noicons",
-		"icons!",
-		"ignorecase",
-		"noignorecase",
-		"ignorecase!",
-		"ignoredia",
-		"noignoredia",
-		"ignoredia!",
-		"incsearch",
-		"noincsearch",
-		"incsearch!",
-		"incfilter",
-		"noincfilter",
-		"incfilter!",
-		"mouse",
-		"nomouse",
-		"mouse!",
-		"number",
-		"nonumber",
-		"number!",
-		"preview",
-		"nopreview",
-		"preview!",
-		"relativenumber",
-		"norelativenumber",
-		"relativenumber!",
-		"reverse",
-		"noreverse",
-		"reverse!",
-		"ruler",
-		"rulerfmt",
-		"preserve",
-		"selectfmt",
-		"sixel",
-		"nosixel",
-		"sixel!",
-		"smartcase",
-		"nosmartcase",
-		"smartcase!",
-		"smartdia",
-		"nosmartdia",
-		"smartdia!",
-		"waitmsg",
-		"wrapscan",
-		"nowrapscan",
-		"wrapscan!",
-		"wrapscroll",
-		"nowrapscroll",
-		"wrapscroll!",
-		"findlen",
-		"period",
-		"scrolloff",
-		"tabstop",
-		"errorfmt",
-		"filesep",
-		"hiddenfiles",
-		"ifs",
-		"info",
-		"numberfmt",
-		"previewer",
-		"cleaner",
-		"promptfmt",
-		"ratios",
-		"selmode",
-		"shell",
-		"shellflag",
-		"shellopts",
-		"sortby",
-		"statfmt",
-		"timefmt",
-		"tempmarks",
-		"tagfmt",
-		"infotimefmtnew",
-		"infotimefmtold",
-		"truncatechar",
-		"truncatepct",
+	gOptWords      = getOptWords(gOpts)
+	gLocalOptWords = getLocalOptWords(gLocalOpts)
+)
+
+func getOptWords(opts any) (optWords []string) {
+	t := reflect.TypeOf(opts)
+	for i := 0; i < t.NumField(); i++ {
+		field := t.Field(i)
+		switch field.Type.Kind() {
+		case reflect.Map:
+			continue
+		case reflect.Bool:
+			name := field.Name
+			optWords = append(optWords, name, "no"+name, name+"!")
+		default:
+			optWords = append(optWords, field.Name)
+		}
 	}
+	sort.Strings(optWords)
+	return
+}
 
-	gLocalOptWords = []string{
-		"dirfirst",
-		"nodirfirst",
-		"dirfirst!",
-		"dironly",
-		"nodironly",
-		"dironly!",
-		"hidden",
-		"nohidden",
-		"hidden!",
-		"info",
-		"reverse",
-		"noreverse",
-		"reverse!",
-		"sortby",
+func getLocalOptWords(localOpts any) (localOptWords []string) {
+	t := reflect.TypeOf(localOpts)
+	for i := 0; i < t.NumField(); i++ {
+		field := t.Field(i)
+		name := strings.TrimSuffix(field.Name, "s")
+		if field.Type.Kind() != reflect.Map {
+			continue
+		}
+		if field.Type.Elem().Kind() == reflect.Bool {
+			localOptWords = append(localOptWords, name, "no"+name, name+"!")
+		} else {
+			localOptWords = append(localOptWords, name)
+		}
 	}
-)
+	sort.Strings(localOptWords)
+	return
+}
 
 func matchLongest(s1, s2 []rune) []rune {
 	i := 0
diff --git a/doc.md b/doc.md
index 90dfddc..92dee46 100644
--- a/doc.md
+++ b/doc.md
@@ -224,6 +224,7 @@ The following environment variables are exported for shell commands:
 	lf_width
 	lf_height
 	lf_count
+	lf_mode
 
 The following special shell commands are used to customize the behavior of lf when defined:
 
@@ -1090,6 +1091,12 @@ Width/Height of the terminal.
 
 Value of the count associated with the current command.
 
+## lf_mode
+
+Current mode that `lf` is operating in.
+This is useful for customizing keybindings depending on what the current mode is.
+Possible values are `delete`, `rename`, `filter`, `find`, `mark`, `search`, `command`, `shell`, `pipe` (when running a shell-pipe command), `normal`, and `unknown`.
+
 # SPECIAL COMMANDS
 
 This section shows information about special shell commands.
diff --git a/doc.txt b/doc.txt
index 67ad728..f932ec1 100644
--- a/doc.txt
+++ b/doc.txt
@@ -214,6 +214,7 @@ The following environment variables are exported for shell commands:
     lf_width
     lf_height
     lf_count
+    lf_mode
 
 The following special shell commands are used to customize the behavior
 of lf when defined:
@@ -1201,6 +1202,13 @@ lf_count
 
 Value of the count associated with the current command.
 
+lf_mode
+
+Current mode that lf is operating in. This is useful for customizing
+keybindings depending on what the current mode is. Possible values are
+delete, rename, filter, find, mark, search, command, shell, pipe (when
+running a shell-pipe command), normal, and unknown.
+
 SPECIAL COMMANDS
 
 This section shows information about special shell commands.
diff --git a/eval.go b/eval.go
index 6979aed..17e927f 100644
--- a/eval.go
+++ b/eval.go
@@ -438,8 +438,8 @@ func (e *setExpr) eval(app *app, args []string) {
 }
 
 func (e *setLocalExpr) eval(app *app, args []string) {
-	path := replaceTilde(e.path)
-	if !filepath.IsAbs(path) {
+	e.path = replaceTilde(e.path)
+	if !filepath.IsAbs(e.path) {
 		app.ui.echoerr("setlocal: path should be absolute")
 		return
 	}
@@ -476,7 +476,7 @@ func (e *setLocalExpr) eval(app *app, args []string) {
 		}
 	case "info":
 		if e.val == "" {
-			gLocalOpts.infos[path] = nil
+			gLocalOpts.infos[e.path] = nil
 			return
 		}
 		toks := strings.Split(e.val, ":")
@@ -488,14 +488,14 @@ func (e *setLocalExpr) eval(app *app, args []string) {
 				return
 			}
 		}
-		gLocalOpts.infos[path] = toks
+		gLocalOpts.infos[e.path] = toks
 	case "sortby":
 		method := sortMethod(e.val)
 		if !isValidSortMethod(method) {
 			app.ui.echoerr(invalidSortErrorMessage)
 			return
 		}
-		gLocalOpts.sortbys[path] = method
+		gLocalOpts.sortbys[e.path] = method
 		app.nav.sort()
 		app.ui.sort()
 	default:
diff --git a/go.mod b/go.mod
index 30f480f..26e6564 100644
--- a/go.mod
+++ b/go.mod
@@ -6,8 +6,8 @@ require (
 	github.com/djherbis/times v1.6.0
 	github.com/gdamore/tcell/v2 v2.7.0
 	github.com/mattn/go-runewidth v0.0.15
-	golang.org/x/sys v0.16.0
-	golang.org/x/term v0.16.0
+	golang.org/x/sys v0.17.0
+	golang.org/x/term v0.17.0
 )
 
 require (
diff --git a/go.sum b/go.sum
index 4f1c012..0ec65b0 100644
--- a/go.sum
+++ b/go.sum
@@ -31,14 +31,14 @@ golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBc
 golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
-golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y=
+golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
 golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
 golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
-golang.org/x/term v0.16.0 h1:m+B6fahuftsE9qjo0VWp2FW0mB3MTJvR0BaMQrq0pmE=
-golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
+golang.org/x/term v0.17.0 h1:mkTF7LCd6WGJNL3K1Ad7kwxNfYAW6a8a8QqtMblp/4U=
+golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
diff --git a/lf.1 b/lf.1
index 7026775..73ee3df 100644
--- a/lf.1
+++ b/lf.1
@@ -1,6 +1,6 @@
 .\" Automatically generated by Pandoc 2.11.4
 .\"
-.TH "LF" "1" "2024-01-18" "" "DOCUMENTATION"
+.TH "LF" "1" "2024-02-02" "" "DOCUMENTATION"
 .hy
 .SH NAME
 .PP
@@ -236,6 +236,7 @@ lf_user_{option}
 lf_width
 lf_height
 lf_count
+lf_mode
 \f[R]
 .fi
 .PP
@@ -1216,6 +1217,15 @@ Width/Height of the terminal.
 .SS lf_count
 .PP
 Value of the count associated with the current command.
+.SS lf_mode
+.PP
+Current mode that \f[C]lf\f[R] is operating in.
+This is useful for customizing keybindings depending on what the current
+mode is.
+Possible values are \f[C]delete\f[R], \f[C]rename\f[R],
+\f[C]filter\f[R], \f[C]find\f[R], \f[C]mark\f[R], \f[C]search\f[R],
+\f[C]command\f[R], \f[C]shell\f[R], \f[C]pipe\f[R] (when running a
+shell-pipe command), \f[C]normal\f[R], and \f[C]unknown\f[R].
 .SH SPECIAL COMMANDS
 .PP
 This section shows information about special shell commands.
diff --git a/ui.go b/ui.go
index 17e53cb..10c0f90 100644
--- a/ui.go
+++ b/ui.go
@@ -1512,6 +1512,43 @@ func (ui *ui) resume() error {
 	return err
 }
 
+func (ui *ui) exportMode() {
+	getMode := func() string {
+		if strings.HasPrefix(ui.cmdPrefix, "delete") {
+			return "delete"
+		}
+
+		if strings.HasPrefix(ui.cmdPrefix, "replace") || strings.HasPrefix(ui.cmdPrefix, "create") {
+			return "rename"
+		}
+
+		switch ui.cmdPrefix {
+		case "filter: ":
+			return "filter"
+		case "find: ", "find-back: ":
+			return "find"
+		case "mark-save: ", "mark-load: ", "mark-remove: ":
+			return "mark"
+		case "rename: ":
+			return "rename"
+		case "/", "?":
+			return "search"
+		case ":":
+			return "command"
+		case "$", "%", "!", "&":
+			return "shell"
+		case ">":
+			return "pipe"
+		case "":
+			return "normal"
+		default:
+			return "unknown"
+		}
+	}
+
+	os.Setenv("lf_mode", getMode())
+}
+
 func (ui *ui) exportSizes() {
 	w, h := ui.screen.Size()
 	os.Setenv("lf_width", strconv.Itoa(w))


Test patch:
diff --git a/complete_test.go b/complete_test.go
index 36994dd..51896b9 100644
--- a/complete_test.go
+++ b/complete_test.go
@@ -54,3 +54,41 @@ func TestMatchWord(t *testing.T) {
 		}
 	}
 }
+
+func TestGetOptWords(t *testing.T) {
+	tests := []struct {
+		opts any
+		exp  []string
+	}{
+		{struct{ feature bool }{}, []string{"feature", "feature!", "nofeature"}},
+		{struct{ feature int }{}, []string{"feature"}},
+		{struct{ feature string }{}, []string{"feature"}},
+		{struct{ feature []string }{}, []string{"feature"}},
+	}
+
+	for _, test := range tests {
+		result := getOptWords(test.opts)
+		if !reflect.DeepEqual(result, test.exp) {
+			t.Errorf("at input '%#v' expected '%s' but got '%s'", test.opts, test.exp, result)
+		}
+	}
+}
+
+func TestGetLocalOptWords(t *testing.T) {
+	tests := []struct {
+		localOpts any
+		exp       []string
+	}{
+		{struct{ features map[string]bool }{}, []string{"feature", "feature!", "nofeature"}},
+		{struct{ features map[string]int }{}, []string{"feature"}},
+		{struct{ features map[string]string }{}, []string{"feature"}},
+		{struct{ features map[string][]string }{}, []string{"feature"}},
+	}
+
+	for _, test := range tests {
+		result := getLocalOptWords(test.localOpts)
+		if !reflect.DeepEqual(result, test.exp) {
+			t.Errorf("at input '%#v' expected '%s' but got '%s'", test.localOpts, test.exp, result)
+		}
+	}
+}