Repository: gokcehan/lf
Pull request #2021 (2025-06-26 06:49:15)

Add `visual-mode`
- Fixes https://github.com/gokcehan/lf/issues/477

This pull request adds a `visual mode` to `lf`, based on the one in `ranger`. A feature @gokcehan has previously expressed interest in.

- Add functionality for visually selecting files
- Add keymap for Visual mode, and supporting `nmap` and `vmap` commands
- Add mode column for `maps` command, also add `nmaps` and `vmaps` commands
- Add `visualfmt` option for configuring appearance for the visual selection
- Add `%m/%M` placeholders for displaying the mode
- Add marker indicating the number of visually selected files in the ruler
- Add `visual` as an identifier when exporting `lf_mode`

joelim-work: Thanks for your interest in this feature. So the reason why Visual mode doesn't exist is because it adds a lot of complexity for something that is already somewhat handled by the selection list (controlled by `toggle`).

Even with the current version of `lf`, it's already possible to simulate Visual mode with configuration alone, although it is rather limited:

```sh
set user_visual ""
set rulerfmt "\033[7;36m %{lf_user_visual} \033[0m"

map V &{{
    if [ -z "$lf_user_visual" ]; then
        lf -remote "send $id :set user_visual visual; unselect; toggle"
    else
        lf -remote "send $id :set user_visual ''; unselect"
    fi
}}

map j &{{
    if [ -z "$lf_user_visual" ]; then
        lf -remote "send $id down"
    else
        lf -remote "send $id :down; toggle"
    fi
}}

map k &{{
    if [ -z "$lf_user_visual" ]; then
        lf -remote "send $id up"
    else
        lf -remote "send $id :toggle; up"
    fi
}}

map d :set user_visual ''; cut
map y :set user_visual ''; copy
map D :set user_visual ''; delete
```

That being said, I am not against adding built-in support for Visual mode, but there are a number of things to consider:

- Visual mode should be implemented as a separate set of keybindings. This means adding another `map[string]expr` for Visual mode as well as other related commands like `vmap <keys> <expr>` and `vmaps`. Otherwise it is difficult to provide a default keybinding that does something different depending on whether the user is in Normal or Visual mode (luckily `o` is not bound to anything in Normal mode by default, but that may not always be the case).
- Should Visual mode be a global state or just within the context of a single directory? I tried this in `ranger` and apparently it is the latter (I haven't tried other file managers though). In other words if you enter Visual mode, then change out of the directory and back, you will still be in Visual mode, and directories are either in Visual mode or they are not. This is actually similar to how the `filter` command works in `lf`.
- Should the anchor (and the resulting visual selection) be based on the filename or index? What should happen if:
  - The order of the files is changed by `set sortby` (can be triggered externally by `lf -remote`) while in Visual mode?
  - The `period`/`watch` option is enabled and files are added/removed, or a `filter` is applied?
  - The file marked as the anchor is deleted or renamed?
- In `vim` you can search with `/` while in Visual mode, while will move the cursor and update the visual selection. Should `lf` be able to do the same too?
- In your PR implementation, the list of selections is updated each time the cursor moves. This means that exiting Visual mode will unconditionally leave the visual selection as part of the selection list. Instead there should be another separate list (can be logically represented by the anchor and cursor), which the UI will mark as highlighted in Visual mode, and the user should be able to exit Visual mode in two ways - one which adds the visual selection to the selection list and another that doesn't (i.e. cancel the visual selection).
- Currently `lf` already has two types of selections, the list of cut/copied files and the selection list. It is actually possible for a file to be both marked as cut/copied and also be in the selection list - in this case the UI will show it as selected (`selectfmt`). Adding Visual mode introduces a third type of selection (I prefer the name `visualfmt` over `selectionfmt` here), I guess this should take precedence over the other two when displaying in the UI. As for the color, magenta/red/yellow is already used for `selectfmt`/`cutfmt`/`copyfmt` respectively, and the filter is shown using blue, maybe cyan could work here?
- I would still prefer to fully see the `statfmt` instead of `--VISUAL--` even when in Visual mode. Instead an indicator should be added to `rulerfmt` which indicates whether the user is in Visual mode or not (this can just be the word `visual` or a count indicating the number of visually selected files).
- I wonder if a user will eventually request the visual selection to be exported (e.g. `$fv`) for scripting purposes. I would not worry about this now though since it is too similar to exporting the selection list (`$fs`).
- The `invert-below` command was introduced as an experimental feature as a temporary substitute for Visual mode. If Visual mode does end up getting supported then `invert-below` should be removed. I don't think there is any need for a deprecation period since it was documented as experimental to begin with.
CatsDeservePets: 
> * Visual mode should be implemented as a separate set of keybindings. This means adding another `map[string]expr` for Visual mode as well as other related commands like `vmap <keys> <expr>` and `vmaps`. Otherwise it is difficult to provide a default keybinding that does something different depending on whether the user is in Normal or Visual mode (luckily `o` is not bound to anything in Normal mode by default, but that may not always be the case).

I was thinking about doing it like this but thought it was overblown and would introduce a lot of repetitive assignments. I will update the code accordingly.


> * Should Visual mode be a global state or just within the context of a single directory? I tried this in `ranger` and apparently it is the latter (I haven't tried other file managers though). In other words if you enter Visual mode, then change out of the directory and back, you will still be in Visual mode, and directories are either in Visual mode or they are not. This is actually similar to how the `filter` command works in `lf`.

- `ranger`: No difference between selection and visual selection. Changing the directory stops `visual mode` and keeps the selection. Coming back to the directory it stays in `normal mode`.
- `joshuto`: Visual difference between selection and visual selection. Changing the directory stops `visual mode` and keeps the visual selection. Coming back to the directory it switches to `visual mode` again.
- `yazi`: Visual difference between selection and visual selection. Changing the directory stops `select mode` and adds the visual selection to the normal selection. Coming back to the directory it stays in `normal mode`.

There are multiple ways of approaching this.


> * Should the anchor (and the resulting visual selection) be based on the filename or index? What should happen if:
>   
>   * The order of the files is changed by `set sortby` (can be triggered externally by `lf -remote`) while in Visual mode?
>   * The `period`/`watch` option is enabled and files are added/removed, or a `filter` is applied?
>   * The file marked as the anchor is deleted or renamed?

Those are valid questions. I tested it with `vim` by creating a file with numbers and selecting them while also editing the file in another `vim` instance. Anchor stays at the same position and if this position is no longer available, it moves to the next possible position. It even stays in `visual mode` when there is nothing left to select.


> * In `vim` you can search with `/` while in Visual mode, while will move the cursor and update the visual selection. Should `lf` be able to do the same too?

I just recently learned about this behaviour in `vim` and it blew me away. I am definitely in favour of this. 


> * In your PR implementation, the list of selections is updated each time the cursor moves. This means that exiting Visual mode will unconditionally leave the visual selection as part of the selection list. Instead there should be another separate list (can be logically represented by the anchor and cursor), which the UI will mark as highlighted in Visual mode, and the user should be able to exit Visual mode in two ways - one which adds the visual selection to the selection list and another that doesn't (i.e. cancel the visual selection).

Yeah, I already mentioned this in my PR, this seems logical behaviour.


> * Currently `lf` already has two types of selections, the list of cut/copied files and the selection list. It is actually possible for a file to be both marked as cut/copied and also be in the selection list - in this case the UI will show it as selected (`selectfmt`). Adding Visual mode introduces a third type of selection (I prefer the name `visualfmt` over `selectionfmt` here), I guess this should take precedence over the other two when displaying in the UI. As for the color, magenta/red/yellow is already used for `selectfmt`/`cutfmt`/`copyfmt` respectively, and the filter is shown using blue, maybe cyan could work here?

Cyan is also what `yazi` uses, I think it is a good fit.


> * I would still prefer to fully see the `statfmt` instead of `--VISUAL--` even when in Visual mode. Instead an indicator should be added to `rulerfmt` which indicates whether the user is in Visual mode or not (this can just be the word `visual` or a count indicating the number of visually selected files).

`rulerfmt` seems to be the obvious way to go. My personal problem with `rulerfmt` is that it only shows on the right while `statfmt` is on the left which is the typical location for showing the mode in vim like tools. How about showing the count in `rulerfmt` and introducing another option for `statfmt` like `%P` which generally shows permissions but **--VISUAL--** in visual mode to match `ranger` for people that want it? This might lead to the discussion to just add `mode` as it's own option but this should go to `rulerfmt` instead of `statfmt`, which again I personally don't like as it is on the right.


> * The `invert-below` command was introduced as an experimental feature as a temporary substitute for Visual mode. If Visual mode does end up getting supported then `invert-below` should be removed. I don't think there is any need for a deprecation period since it was documented as experimental to begin with.

Noted.

CatsDeservePets: Ok I now noticed that `lf`s behaviour when sorting is to go to the new position of the previously selected file instead of keeping the position and therefore selecting a new file.
Having `visual mode` stay at the position instead to match `vim`s behaviour doesn't seem right to me.
CatsDeservePets: @joelim-work 
I think the overall implementation is done. I am not sure whether I like all those calls to `app.nav.updateVisualSelections()` though.
I did not update the docs again, I will wait for this till everything else it set. 

Regarding the docs, I also have a question: When exactly does a command count as `(modal)` inside the docs? The explanation makes it sound like every command that toggles something counts as `(modal)`, but why isn't `tag-toggle` marked as `(modal)` then? 
CatsDeservePets: > Hi @CatsDeservePets I'm not sure if you missed them, but you only addressed about half the comments I raised. I think what happens is that if there are a lot of comments then GitHub will collapse some of them to avoid taking up too much space on the screen.

Thanks for pointing it out. GitHub does indeed collapse them by itself. I thought, the collapsed ones just were the resolved ones...

Please take a look again.

Patch:
diff --git a/app.go b/app.go
index 68e4695..56b73ea 100644
--- a/app.go
+++ b/app.go
@@ -391,6 +391,7 @@ func (app *app) loop() {
 				if ok {
 					d.ind = prev.ind
 					d.pos = prev.pos
+					d.visualAnchor = min(prev.visualAnchor, len(d.files)-1)
 					d.filter = prev.filter
 					d.sort()
 					d.sel(prev.name(), app.nav.height)
@@ -552,14 +553,25 @@ 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()
+	app.exportMode()
 	exportLfPath()
 	exportOpts()
 
 	gState.mutex.Lock()
-	gState.data["maps"] = listBinds(gOpts.keys)
-	gState.data["cmaps"] = listBinds(gOpts.cmdkeys)
-	gState.data["cmds"] = listCmds()
+	gState.data["maps"] = listBinds(map[string]map[string]expr{
+		"n": gOpts.nkeys,
+		"v": gOpts.vkeys,
+	})
+	gState.data["nmaps"] = listBinds(map[string]map[string]expr{
+		"n": gOpts.nkeys,
+	})
+	gState.data["vmaps"] = listBinds(map[string]map[string]expr{
+		"v": gOpts.vkeys,
+	})
+	gState.data["cmaps"] = listBinds(map[string]map[string]expr{
+		"c": gOpts.cmdkeys,
+	})
+	gState.data["cmds"] = listCmds(gOpts.cmds)
 	gState.data["jumps"] = listJumps(app.nav.jumpList, app.nav.jumpListInd)
 	gState.data["history"] = listHistory(app.cmdHistory)
 	gState.data["files"] = listFilesInCurrDir(app.nav)
@@ -663,3 +675,43 @@ func (app *app) watchDir(dir *dir) {
 		}
 	}
 }
+
+func (app *app) exportMode() {
+	getMode := func() string {
+		if strings.HasPrefix(app.ui.cmdPrefix, "delete") {
+			return "delete"
+		}
+
+		if strings.HasPrefix(app.ui.cmdPrefix, "replace") || strings.HasPrefix(app.ui.cmdPrefix, "create") {
+			return "rename"
+		}
+
+		switch app.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 "":
+			if app.nav.isVisualMode() {
+				return "visual"
+			}
+			return "normal"
+		default:
+			return "unknown"
+		}
+	}
+
+	os.Setenv("lf_mode", getMode())
+}
diff --git a/complete.go b/complete.go
index 494d050..1d45dc8 100644
--- a/complete.go
+++ b/complete.go
@@ -14,6 +14,8 @@ var (
 		"set",
 		"setlocal",
 		"map",
+		"nmap",
+		"vmap",
 		"cmap",
 		"cmd",
 		"quit",
@@ -36,7 +38,6 @@ var (
 		"low",
 		"toggle",
 		"invert",
-		"invert-below",
 		"unselect",
 		"glob-select",
 		"glob-unselect",
@@ -110,6 +111,10 @@ var (
 		"cmd-capitalize-word",
 		"cmd-uppercase-word",
 		"cmd-lowercase-word",
+		"visual",
+		"visual-accept",
+		"visual-discard",
+		"visual-change",
 	}
 
 	gOptWords      = getOptWords(gOpts)
@@ -329,7 +334,7 @@ func completeCmd(acc []rune) (matches []string, longestAcc []rune) {
 		case "set":
 			matches, longest = matchWord(f[1], gOptWords)
 			longestAcc = append(acc[:len(acc)-len([]rune(f[len(f)-1]))], longest...)
-		case "map", "cmap", "cmd":
+		case "map", "nmap", "vmap", "cmap", "cmd":
 			longestAcc = acc
 		default:
 			matches, longest = matchFile(f[len(f)-1])
@@ -340,7 +345,7 @@ func completeCmd(acc []rune) (matches []string, longestAcc []rune) {
 		case "setlocal":
 			matches, longest = matchWord(f[2], gLocalOptWords)
 			longestAcc = append(acc[:len(acc)-len([]rune(f[len(f)-1]))], longest...)
-		case "map", "cmap":
+		case "map", "nmap", "vmap", "cmap":
 			matches, longest = matchCmd(f[2])
 			longestAcc = append(acc[:len(acc)-len([]rune(f[len(f)-1]))], longest...)
 		case "addcustominfo":
@@ -351,7 +356,7 @@ func completeCmd(acc []rune) (matches []string, longestAcc []rune) {
 		}
 	default:
 		switch f[0] {
-		case "set", "setlocal", "map", "cmap", "cmd":
+		case "set", "setlocal", "map", "nmap", "vmap", "cmap", "cmd":
 			longestAcc = acc
 		default:
 			matches, longest = matchFile(f[len(f)-1])
diff --git a/doc.md b/doc.md
index 3466a65..f19b605 100644
--- a/doc.md
+++ b/doc.md
@@ -57,8 +57,8 @@ The following commands are provided by lf:
 	middle                   (default 'M')
 	low                      (default 'L')
 	toggle
+	visual                   (default 'V')
 	invert                   (default 'v')
-	invert-below
 	unselect                 (default 'u')
 	glob-select
 	glob-unselect
@@ -105,6 +105,12 @@ The following commands are provided by lf:
 	addcustominfo
 	tty-write
 
+The following visual mode commands are provided by lf:
+
+	visual-accept            (default 'V')
+	visual-discard           (default '<esc>')
+	visual-change            (default 'o')
+
 The following command line commands are provided by lf:
 
 	cmd-escape               (default '<esc>')
@@ -203,6 +209,7 @@ The following options can be used to customize the behavior of lf:
 	timefmt           string    (default 'Mon Jan _2 15:04:05 2006')
 	truncatechar      string    (default '~')
 	truncatepct       int       (default 100)
+	visualfmt         string    (default "\033[7;36m")
 	waitmsg           string    (default 'Press any key to continue')
 	watch             bool      (default false)
 	wrapscan          bool      (default true)
@@ -257,6 +264,8 @@ The following commands/keybindings are provided by default:
 	cmd doc $$lf -doc | $PAGER
 	map <f-1> doc
 	cmd maps $lf -remote "query $id maps" | $PAGER
+	cmd nmaps $lf -remote "query $id nmaps" | $PAGER
+	cmd vmaps $lf -remote "query $id vmaps" | $PAGER
 	cmd cmaps $lf -remote "query $id cmaps" | $PAGER
 	cmd cmds $lf -remote "query $id cmds" | $PAGER
 
@@ -268,6 +277,8 @@ The following commands/keybindings are provided by default:
 	cmd doc !%lf% -doc | %PAGER%
 	map <f-1> doc
 	cmd maps !%lf% -remote "query %id% maps" | %PAGER%
+	cmd nmaps !%lf% -remote "query %id% nmaps" | %PAGER%
+	cmd vmaps !%lf% -remote "query %id% vmaps" | %PAGER%
 	cmd cmaps !%lf% -remote "query %id% cmaps" | %PAGER%
 	cmd cmds !%lf% -remote "query %id% cmds" | %PAGER%
 
@@ -286,7 +297,7 @@ The following additional keybindings are provided by default:
 	map sc :set sortby ctime; set info ctime
 	map se :set sortby ext; set info
 	map gh cd ~
-	map <space> :toggle; down
+	nmap <space> :toggle; down
 
 If the `mouse` option is enabled, mouse buttons have the following default effects:
 
@@ -401,22 +412,28 @@ Move the current file selection to the high/middle/low of the screen.
 
 Toggle the selection of the current file or files given as arguments.
 
-## invert (default `v`)
+## visual (modal) (default 'V')
 
-Reverse the selection of all files in the current directory (i.e. `toggle` all files).
-Selections in other directories are not affected by this command.
-You can define a new command to select all files in the directory by combining `invert` with `unselect` (i.e. `cmd select-all :unselect; invert`), though this will also remove selections in other directories.
+Switch to visual mode.
+If already in visual mode, discard visual selection and stay in visual mode.
 
-## invert-below
+## visual-accept (modal) (default 'V')
 
-Reverse the selection (i.e. `toggle`) of all files at or after the current file in the current directory.
+Add the visual selection to the selection list, quit visual mode and return to normal mode.
 
-To select a contiguous block of files, use this command on the first file you want to select.
-Then, move down to the first file you do *not* want to select (the one after the end of the desired selection) and use this command again.
-This achieves an effect similar to the visual mode in Vim.
+## visual-discard (modal) (default '<esc>')
+
+Discard the visual selection, quit visual mode and return to normal mode.
+
+## visual-change (default 'o')
+
+Go to the other end of the current visual mode selection.
+
+## invert (default `v`)
 
-This command is experimental and may be removed once a better replacement for the visual mode is implemented in `lf`.
-If you'd like to experiment with using this command, you should bind it to a key (e.g. `V`) for a better experience.
+Reverse the selection of all files in the current directory (i.e. `toggle` all files).
+Selections in other directories are not affected by this command.
+You can define a new command to select all files in the directory by combining `invert` with `unselect` (i.e. `cmd select-all :unselect; invert`), though this will also remove selections in other directories.
 
 ## unselect (default `u`)
 
@@ -434,7 +451,7 @@ If the total size of a directory is not calculated, it will be shown as `-`.
 
 ## clearmaps
 
-Remove all keybindings associated with the `map` command.
+Remove all keybindings associated with the `map`, `nmap` and `vmap` command.
 This command can be used in the config file to remove the default keybindings.
 For safety purposes, `:` is left mapped to the `read` command, and `cmap` keybindings are retained so that it is still possible to exit `lf` using `:quit`.
 
@@ -916,10 +933,10 @@ Reverse the direction of sort.
 
 Draw rounded outer corners when the `drawbox` option is enabled.
 
-## rulerfmt (string) (default `  %a|  %p|  \033[7;31m %m \033[0m|  \033[7;33m %c \033[0m|  \033[7;35m %s \033[0m|  \033[7;34m %f \033[0m|  %i/%t`)
+## rulerfmt (string) (default `  %a|  %p|  \033[7;31m %m \033[0m|  \033[7;33m %c \033[0m|  \033[7;35m %s \033[0m|  \033[7;36m %v \033[0m|  \033[7;34m %f \033[0m|  %i/%t`)
 
 Format string of the ruler shown in the bottom right corner.
-Special expansions are provided, `%a` as the pressed keys, `%p` as the progress of file operations, `%m` as the number of files to be cut (moved), `%c` as the number of files to be copied, `%s` as the number of selected files, `%f` as the filter, `%i` as the position of the cursor, `%t` as the number of files shown in the current directory, `%h` as the number of files hidden in the current directory, `%P` as the scroll percentage, and `%d` as the amount of free disk space remaining.
+Special expansions are provided, `%a` as the pressed keys, `%p` as the progress of file operations, `%m` as the number of files to be cut (moved), `%c` as the number of files to be copied, `%s` as the number of selected files, `%v` as the number of visually selected files, `%f` as the filter, `%i` as the position of the cursor, `%t` as the number of files shown in the current directory, `%h` as the number of files hidden in the current directory, `%P` as the scroll percentage, and `%d` as the amount of free disk space remaining.
 Additional expansions are provided for environment variables exported by lf, in the form `%{lf_<name>}` (e.g. `%{lf_selmode}`). This is useful for displaying the current settings.
 Expansions are also provided for user-defined options, in the form `%{lf_user_<name>}` (e.g. `%{lf_user_foo}`).
 The `|` character splits the format string into sections. Any section containing a failed expansion (result is a blank string) is discarded and not shown.
@@ -979,7 +996,7 @@ Currently supported sort types are `natural`, `name`, `size`, `time`, `ctime`, `
 ## statfmt (string) (default `\033[36m%p\033[0m| %c| %u| %g| %S| %t| -> %l`)
 
 Format string of the file info shown in the bottom left corner.
-Special expansions are provided, `%p` as the file permissions, `%c` as the link count, `%u` as the user, `%g` as the group, `%s` as the file size, `%S` as the file size but with a fixed width of four characters (left-padded with spaces), `%t` as the last modified time, and `%l` as the link target.
+Special expansions are provided, `%p` as the file permissions, `%c` as the link count, `%u` as the user, `%g` as the group, `%s` as the file size, `%S` as the file size but with a fixed width of four characters (left-padded with spaces), `%t` as the last modified time, `%l` as the link target, `%m` as the current mode and `%M` as the current mode but also shown in normal mode (displaying NORMAL instead of a blank string).
 The `|` character splits the format string into sections. Any section containing a failed expansion (result is a blank string) is discarded and not shown.
 
 ## tabstop (int) (default 8)
@@ -1024,6 +1041,10 @@ while a value of 0 will only show the end of the filename, e.g.:
 
 - `set truncatepct 0`   -> `~ng-filename-truncated`
 
+## visualfmt (string) (default `\033[7;36m`)
+
+Format string of the indicator for files that are visually selected.
+
 ## waitmsg (string) (default `Press any key to continue`)
 
 String shown after commands of shell-wait type.
@@ -1122,7 +1143,7 @@ Value of the count associated with the current command.
 
 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`.
+Possible values are `delete`, `rename`, `filter`, `find`, `mark`, `search`, `command`, `shell`, `pipe` (when running a shell-pipe command), `normal`, `visual` and `unknown`.
 
 # SPECIAL COMMANDS
 
@@ -1195,6 +1216,7 @@ The same evaluator is used for the command line and the configuration file for r
 The difference is that prefixes are not necessary in the command line.
 Instead, different modes are provided to read corresponding commands.
 These modes are mapped to the prefix keys above by default.
+Visual mode mappings are defined the same way normal mode mappings are defined.
 
 # SYNTAX
 
@@ -1202,7 +1224,7 @@ Characters from `#` to newline are comments and ignored:
 
 	# comments start with `#`
 
-There are five special commands (`set`, `setlocal`, `map`, `cmap`, and `cmd`) for configuration.
+The following commands (`set`, `setlocal`, `map`, `nmap`, `vmap`, `cmap`, and `cmd`) are used for configuration.
 
 Command `set` is used to set an option which can be a boolean, integer, or string:
 
@@ -1231,21 +1253,34 @@ Adding a trailing path separator (i.e. `/` for Unix and `\` for Windows) sets th
 	setlocal /foo/bar  hidden        # for only '/foo/bar' directory
 	setlocal /foo/bar/ hidden        # for '/foo/bar' and its subdirectories (e.g. '/foo/bar/baz')
 
-Command 'map' is used to bind a key to a command which can be a builtin command, custom command, or shell command:
+Command `map` is used to bind a key in normal and visual mode to a command which can be a builtin command, custom command, or shell command:
 
 	map gh cd ~        # builtin command
 	map D trash        # custom command
 	map i $less $f     # shell command
 	map U !du -csh *   # waiting shell command
 
-Command 'cmap' is used to bind a key on the command line to a command line command or any other command:
+Command `nmap` does the same but for normal mode only.
+
+Command `vmap` does the same but for visual mode only.
+
+Overview of which map command works in which mode:
+
+	map                Normal, Visual
+	nmap               Normal
+	vmap               Visual
+	cmap               Command-line
+
+Command `cmap` is used to bind a key on the command line to a command line command or any other command:
 
 	cmap <c-g> cmd-escape
 	cmap <a-i> set incsearch!
 
 You can delete an existing binding by leaving the expression empty:
 
-	map gh             # deletes 'gh' mapping
+	map gh             # deletes 'gh' mapping in normal and visual mode
+	nmap v             # deletes 'v' mapping in normal mode
+	vmap o             # deletes 'o' mapping in visual mode
 	cmap <c-g>         # deletes '<c-g>' mapping
 
 Command `cmd` is used to define a custom command:
@@ -1486,13 +1521,21 @@ In addition, the `query` command can be used to obtain information about a speci
 
 The following types of information are supported:
 
-	maps     list of mappings created by the 'map' command
+	maps     list of mappings created by the 'map', 'nmap' and 'vmap' command
+	nmaps    list of mappings created by the 'nmap' and 'map' command
+	vmaps    list of mappings created by the 'vmap' and 'map' command
 	cmaps    list of mappings created by the 'cmap' command
 	cmds     list of commands created by the 'cmd' command
 	jumps    contents of the jump list, showing previously visited locations
 	history  list of previously executed commands on the command line
 	files    list of files in the currently open directory as displayed by lf, empty if dir is still loading
 
+When listing mappings the characters in the first column are:
+
+	n  Normal
+	v  Visual
+	c  Command-line
+
 This is useful for scripting actions based on the internal state of lf.
 For example, to select a previous command using fzf and execute it:
 
diff --git a/doc.txt b/doc.txt
index 1f55139..741835d 100644
--- a/doc.txt
+++ b/doc.txt
@@ -47,8 +47,8 @@ The following commands are provided by lf:
     middle                   (default 'M')
     low                      (default 'L')
     toggle
+    visual                   (default 'V')
     invert                   (default 'v')
-    invert-below
     unselect                 (default 'u')
     glob-select
     glob-unselect
@@ -95,6 +95,12 @@ The following commands are provided by lf:
     addcustominfo
     tty-write
 
+The following visual mode commands are provided by lf:
+
+    visual-accept            (default 'V')
+    visual-discard           (default '<esc>')
+    visual-change            (default 'o')
+
 The following command line commands are provided by lf:
 
     cmd-escape               (default '<esc>')
@@ -193,6 +199,7 @@ The following options can be used to customize the behavior of lf:
     timefmt           string    (default 'Mon Jan _2 15:04:05 2006')
     truncatechar      string    (default '~')
     truncatepct       int       (default 100)
+    visualfmt         string    (default "\033[7;36m")
     waitmsg           string    (default 'Press any key to continue')
     watch             bool      (default false)
     wrapscan          bool      (default true)
@@ -248,6 +255,8 @@ The following commands/keybindings are provided by default:
     cmd doc $$lf -doc | $PAGER
     map <f-1> doc
     cmd maps $lf -remote "query $id maps" | $PAGER
+    cmd nmaps $lf -remote "query $id nmaps" | $PAGER
+    cmd vmaps $lf -remote "query $id vmaps" | $PAGER
     cmd cmaps $lf -remote "query $id cmaps" | $PAGER
     cmd cmds $lf -remote "query $id cmds" | $PAGER
 
@@ -259,6 +268,8 @@ The following commands/keybindings are provided by default:
     cmd doc !%lf% -doc | %PAGER%
     map <f-1> doc
     cmd maps !%lf% -remote "query %id% maps" | %PAGER%
+    cmd nmaps !%lf% -remote "query %id% nmaps" | %PAGER%
+    cmd vmaps !%lf% -remote "query %id% vmaps" | %PAGER%
     cmd cmaps !%lf% -remote "query %id% cmaps" | %PAGER%
     cmd cmds !%lf% -remote "query %id% cmds" | %PAGER%
 
@@ -277,7 +288,7 @@ The following additional keybindings are provided by default:
     map sc :set sortby ctime; set info ctime
     map se :set sortby ext; set info
     map gh cd ~
-    map <space> :toggle; down
+    nmap <space> :toggle; down
 
 If the mouse option is enabled, mouse buttons have the following default
 effects:
@@ -400,6 +411,25 @@ toggle
 
 Toggle the selection of the current file or files given as arguments.
 
+visual (modal) (default 'V')
+
+Switch to visual mode. If already in visual mode, discard visual
+selection and stay in visual mode.
+
+visual-accept (modal) (default 'V')
+
+Add the visual selection to the selection list, quit visual mode and
+return to normal mode.
+
+visual-discard (modal) (default '')
+
+Discard the visual selection, quit visual mode and return to normal
+mode.
+
+visual-change (default 'o')
+
+Go to the other end of the current visual mode selection.
+
 invert (default v)
 
 Reverse the selection of all files in the current directory (i.e. toggle
@@ -409,22 +439,6 @@ directory by combining invert with unselect (i.e.
 cmd select-all :unselect; invert), though this will also remove
 selections in other directories.
 
-invert-below
-
-Reverse the selection (i.e. toggle) of all files at or after the current
-file in the current directory.
-
-To select a contiguous block of files, use this command on the first
-file you want to select. Then, move down to the first file you do not
-want to select (the one after the end of the desired selection) and use
-this command again. This achieves an effect similar to the visual mode
-in Vim.
-
-This command is experimental and may be removed once a better
-replacement for the visual mode is implemented in lf. If you'd like to
-experiment with using this command, you should bind it to a key (e.g. V)
-for a better experience.
-
 unselect (default u)
 
 Remove the selection of all files in all directories.
@@ -442,10 +456,11 @@ be shown as -.
 
 clearmaps
 
-Remove all keybindings associated with the map command. This command can
-be used in the config file to remove the default keybindings. For safety
-purposes, : is left mapped to the read command, and cmap keybindings are
-retained so that it is still possible to exit lf using :quit.
+Remove all keybindings associated with the map, nmap and vmap command.
+This command can be used in the config file to remove the default
+keybindings. For safety purposes, : is left mapped to the read command,
+and cmap keybindings are retained so that it is still possible to exit
+lf using :quit.
 
 copy (default y)
 
@@ -991,22 +1006,23 @@ roundbox (bool) (default false)
 
 Draw rounded outer corners when the drawbox option is enabled.
 
-rulerfmt (string) (default   %a|  %p|  \033[7;31m %m \033[0m|  \033[7;33m %c \033[0m|  \033[7;35m %s \033[0m|  \033[7;34m %f \033[0m|  %i/%t)
+rulerfmt (string) (default   %a|  %p|  \033[7;31m %m \033[0m|  \033[7;33m %c \033[0m|  \033[7;35m %s \033[0m|  \033[7;36m %v \033[0m|  \033[7;34m %f \033[0m|  %i/%t)
 
 Format string of the ruler shown in the bottom right corner. Special
 expansions are provided, %a as the pressed keys, %p as the progress of
 file operations, %m as the number of files to be cut (moved), %c as the
-number of files to be copied, %s as the number of selected files, %f as
-the filter, %i as the position of the cursor, %t as the number of files
-shown in the current directory, %h as the number of files hidden in the
-current directory, %P as the scroll percentage, and %d as the amount of
-free disk space remaining. Additional expansions are provided for
-environment variables exported by lf, in the form %{lf_<name>} (e.g.
-%{lf_selmode}). This is useful for displaying the current settings.
-Expansions are also provided for user-defined options, in the form
-%{lf_user_<name>} (e.g. %{lf_user_foo}). The | character splits the
-format string into sections. Any section containing a failed expansion
-(result is a blank string) is discarded and not shown.
+number of files to be copied, %s as the number of selected files, %v as
+the number of visually selected files, %f as the filter, %i as the
+position of the cursor, %t as the number of files shown in the current
+directory, %h as the number of files hidden in the current directory, %P
+as the scroll percentage, and %d as the amount of free disk space
+remaining. Additional expansions are provided for environment variables
+exported by lf, in the form %{lf_<name>} (e.g. %{lf_selmode}). This is
+useful for displaying the current settings. Expansions are also provided
+for user-defined options, in the form %{lf_user_<name>} (e.g.
+%{lf_user_foo}). The | character splits the format string into sections.
+Any section containing a failed expansion (result is a blank string) is
+discarded and not shown.
 
 selectfmt (string) (default \033[7;35m)
 
@@ -1069,10 +1085,11 @@ Format string of the file info shown in the bottom left corner. Special
 expansions are provided, %p as the file permissions, %c as the link
 count, %u as the user, %g as the group, %s as the file size, %S as the
 file size but with a fixed width of four characters (left-padded with
-spaces), %t as the last modified time, and %l as the link target. The |
-character splits the format string into sections. Any section containing
-a failed expansion (result is a blank string) is discarded and not
-shown.
+spaces), %t as the last modified time, %l as the link target, %m as the
+current mode and %M as the current mode but also shown in normal mode
+(displaying NORMAL instead of a blank string). The | character splits
+the format string into sections. Any section containing a failed
+expansion (result is a blank string) is discarded and not shown.
 
 tabstop (int) (default 8)
 
@@ -1121,6 +1138,10 @@ will only show the end of the filename, e.g.:
 
 - set truncatepct 0 -> ~ng-filename-truncated
 
+visualfmt (string) (default \033[7;36m)
+
+Format string of the indicator for files that are visually selected.
+
 waitmsg (string) (default Press any key to continue)
 
 String shown after commands of shell-wait type.
@@ -1236,7 +1257,7 @@ 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.
+running a shell-pipe command), normal, visual and unknown.
 
 SPECIAL COMMANDS
 
@@ -1319,7 +1340,8 @@ The same evaluator is used for the command line and the configuration
 file for reading shell commands. The difference is that prefixes are not
 necessary in the command line. Instead, different modes are provided to
 read corresponding commands. These modes are mapped to the prefix keys
-above by default.
+above by default. Visual mode mappings are defined the same way normal
+mode mappings are defined.
 
 SYNTAX
 
@@ -1327,8 +1349,8 @@ Characters from # to newline are comments and ignored:
 
     # comments start with `#`
 
-There are five special commands (set, setlocal, map, cmap, and cmd) for
-configuration.
+The following commands (set, setlocal, map, nmap, vmap, cmap, and cmd)
+are used for configuration.
 
 Command set is used to set an option which can be a boolean, integer, or
 string:
@@ -1360,23 +1382,36 @@ option for the given directory along with its subdirectories:
     setlocal /foo/bar  hidden        # for only '/foo/bar' directory
     setlocal /foo/bar/ hidden        # for '/foo/bar' and its subdirectories (e.g. '/foo/bar/baz')
 
-Command 'map' is used to bind a key to a command which can be a builtin
-command, custom command, or shell command:
+Command map is used to bind a key in normal and visual mode to a command
+which can be a builtin command, custom command, or shell command:
 
     map gh cd ~        # builtin command
     map D trash        # custom command
     map i $less $f     # shell command
     map U !du -csh *   # waiting shell command
 
-Command 'cmap' is used to bind a key on the command line to a command
-line command or any other command:
+Command nmap does the same but for normal mode only.
+
+Command vmap does the same but for visual mode only.
+
+Overview of which map command works in which mode:
+
+    map                Normal, Visual
+    nmap               Normal
+    vmap               Visual
+    cmap               Command-line
+
+Command cmap is used to bind a key on the command line to a command line
+command or any other command:
 
     cmap <c-g> cmd-escape
     cmap <a-i> set incsearch!
 
 You can delete an existing binding by leaving the expression empty:
 
-    map gh             # deletes 'gh' mapping
+    map gh             # deletes 'gh' mapping in normal and visual mode
+    nmap v             # deletes 'v' mapping in normal mode
+    vmap o             # deletes 'o' mapping in visual mode
     cmap <c-g>         # deletes '<c-g>' mapping
 
 Command cmd is used to define a custom command:
@@ -1653,13 +1688,21 @@ specific lf instance by providing its id:
 
 The following types of information are supported:
 
-    maps     list of mappings created by the 'map' command
+    maps     list of mappings created by the 'map', 'nmap' and 'vmap' command
+    nmaps    list of mappings created by the 'nmap' and 'map' command
+    vmaps    list of mappings created by the 'vmap' and 'map' command
     cmaps    list of mappings created by the 'cmap' command
     cmds     list of commands created by the 'cmd' command
     jumps    contents of the jump list, showing previously visited locations
     history  list of previously executed commands on the command line
     files    list of files in the currently open directory as displayed by lf, empty if dir is still loading
 
+When listing mappings the characters in the first column are:
+
+    n  Normal
+    v  Visual
+    c  Command-line
+
 This is useful for scripting actions based on the internal state of lf.
 For example, to select a previous command using fzf and execute it:
 
diff --git a/etc/lfrc.ps1.example b/etc/lfrc.ps1.example
index 2e4b701..2a72656 100644
--- a/etc/lfrc.ps1.example
+++ b/etc/lfrc.ps1.example
@@ -24,5 +24,7 @@ map w $&$Env:SHELL
 cmd doc !&$Env:lf -doc | &$Env:PAGER
 map <f-1> doc
 cmd maps !&$Env:lf -remote "query $Env:id maps" | &$Env:PAGER
+cmd nmaps !&$Env:lf -remote "query $Env:id nmaps" | &$Env:PAGER
+cmd vmaps !&$Env:lf -remote "query $Env:id vmaps" | &$Env:PAGER
 cmd cmaps !&$Env:lf -remote "query $Env:id cmaps" | &$Env:PAGER
 cmd cmds !&$Env:lf -remote "query $Env:id cmds" | &$Env:PAGER
diff --git a/eval.go b/eval.go
index d113e1e..ab18734 100644
--- a/eval.go
+++ b/eval.go
@@ -543,9 +543,29 @@ func (e *setLocalExpr) eval(app *app, args []string) {
 
 func (e *mapExpr) eval(app *app, args []string) {
 	if e.expr == nil {
-		delete(gOpts.keys, e.keys)
+		delete(gOpts.nkeys, e.keys)
+		delete(gOpts.vkeys, e.keys)
 	} else {
-		gOpts.keys[e.keys] = e.expr
+		gOpts.nkeys[e.keys] = e.expr
+		gOpts.vkeys[e.keys] = e.expr
+	}
+	app.ui.loadFileInfo(app.nav)
+}
+
+func (e *nmapExpr) eval(app *app, args []string) {
+	if e.expr == nil {
+		delete(gOpts.nkeys, e.keys)
+	} else {
+		gOpts.nkeys[e.keys] = e.expr
+	}
+	app.ui.loadFileInfo(app.nav)
+}
+
+func (e *vmapExpr) eval(app *app, args []string) {
+	if e.expr == nil {
+		delete(gOpts.vkeys, e.keys)
+	} else {
+		gOpts.vkeys[e.keys] = e.expr
 	}
 	app.ui.loadFileInfo(app.nav)
 }
@@ -803,6 +823,16 @@ func normal(app *app) {
 	app.ui.cmdAccLeft = nil
 	app.ui.cmdAccRight = nil
 	app.ui.cmdPrefix = ""
+
+	// ensure the mode indicator in `statfmt` is updated properly
+	app.ui.loadFileInfo(app.nav)
+}
+
+func visual(app *app) {
+	dir := app.nav.currDir()
+	dir.visualAnchor = dir.ind
+
+	app.ui.loadFileInfo(app.nav)
 }
 
 func insert(app *app, arg string) {
@@ -1298,11 +1328,6 @@ func (e *callExpr) eval(app *app, args []string) {
 			return
 		}
 		app.nav.invert()
-	case "invert-below":
-		if !app.nav.init {
-			return
-		}
-		app.nav.invertBelow()
 	case "unselect":
 		app.nav.unselect()
 	case "calcdirsize":
@@ -1319,8 +1344,10 @@ func (e *callExpr) eval(app *app, args []string) {
 		app.ui.sort()
 	case "clearmaps":
 		// leave `:` and cmaps bound so the user can still exit using `:quit`
-		clear(gOpts.keys)
-		gOpts.keys[":"] = &callExpr{"read", nil, 1}
+		clear(gOpts.nkeys)
+		clear(gOpts.vkeys)
+		gOpts.nkeys[":"] = &callExpr{"read", nil, 1}
+		gOpts.vkeys[":"] = &callExpr{"read", nil, 1}
 	case "copy":
 		if !app.nav.init {
 			return
@@ -2297,6 +2324,42 @@ func (e *callExpr) eval(app *app, args []string) {
 				d.sort()
 			}
 		}
+	case "visual":
+		if !app.nav.init {
+			return
+		}
+		visual(app)
+	case "visual-accept":
+		if !app.nav.init {
+			return
+		}
+		dir := app.nav.currDir()
+		for _, path := range dir.visualSelections() {
+			if _, ok := app.nav.selections[path]; !ok {
+				app.nav.selections[path] = app.nav.selectionInd
+				app.nav.selectionInd++
+			}
+		}
+		// resetting visual mode here instead of inside `normal()`
+		// allows us to use visual mode inside search, find etc.
+		dir.visualAnchor = -1
+		normal(app)
+	case "visual-discard":
+		if !app.nav.init {
+			return
+		}
+		dir := app.nav.currDir()
+		dir.visualAnchor = -1
+		normal(app)
+	case "visual-change":
+		if !app.nav.isVisualMode() {
+			return
+		}
+		dir := app.nav.currDir()
+		beg := max(dir.ind-dir.pos, 0)
+		dir.ind, dir.visualAnchor = dir.visualAnchor, dir.ind
+		dir.pos = dir.ind - beg
+		dir.boundPos(app.nav.height)
 	default:
 		cmd, ok := gOpts.cmds[e.name]
 		if !ok {
diff --git a/lf.1 b/lf.1
index 332663c..d208312 100644
--- a/lf.1
+++ b/lf.1
@@ -1,6 +1,6 @@
 .\" Automatically generated by Pandoc 3.7.0.2
 .\"
-.TH "LF" "1" "2025\-06\-26" "r35\-31\-g4253a28" "DOCUMENTATION"
+.TH "LF" "1" "2025\-07\-03" "" "DOCUMENTATION"
 .SH NAME
 lf \- terminal file manager
 .SH SYNOPSIS
@@ -56,8 +56,8 @@ high                     (default \(aqH\(aq)
 middle                   (default \(aqM\(aq)
 low                      (default \(aqL\(aq)
 toggle
+visual                   (default \(aqV\(aq)
 invert                   (default \(aqv\(aq)
-invert\-below
 unselect                 (default \(aqu\(aq)
 glob\-select
 glob\-unselect
@@ -105,6 +105,14 @@ addcustominfo
 tty\-write
 .EE
 .PP
+The following visual mode commands are provided by lf:
+.IP
+.EX
+visual\-accept            (default \(aqV\(aq)
+visual\-discard           (default \(aq<esc>\(aq)
+visual\-change            (default \(aqo\(aq)
+.EE
+.PP
 The following command line commands are provided by lf:
 .IP
 .EX
@@ -206,6 +214,7 @@ tempmarks         string    (default \(aq\(aq)
 timefmt           string    (default \(aqMon Jan _2 15:04:05 2006\(aq)
 truncatechar      string    (default \(aq\(ti\(aq)
 truncatepct       int       (default 100)
+visualfmt         string    (default \(dq\(rs033[7;36m\(dq)
 waitmsg           string    (default \(aqPress any key to continue\(aq)
 watch             bool      (default false)
 wrapscan          bool      (default true)
@@ -267,6 +276,8 @@ map w $$SHELL
 cmd doc $$lf \-doc | $PAGER
 map <f\-1> doc
 cmd maps $lf \-remote \(dqquery $id maps\(dq | $PAGER
+cmd nmaps $lf \-remote \(dqquery $id nmaps\(dq | $PAGER
+cmd vmaps $lf \-remote \(dqquery $id vmaps\(dq | $PAGER
 cmd cmaps $lf \-remote \(dqquery $id cmaps\(dq | $PAGER
 cmd cmds $lf \-remote \(dqquery $id cmds\(dq | $PAGER
 
@@ -278,6 +289,8 @@ map w $%SHELL%
 cmd doc !%lf% \-doc | %PAGER%
 map <f\-1> doc
 cmd maps !%lf% \-remote \(dqquery %id% maps\(dq | %PAGER%
+cmd nmaps !%lf% \-remote \(dqquery %id% nmaps\(dq | %PAGER%
+cmd vmaps !%lf% \-remote \(dqquery %id% vmaps\(dq | %PAGER%
 cmd cmaps !%lf% \-remote \(dqquery %id% cmaps\(dq | %PAGER%
 cmd cmds !%lf% \-remote \(dqquery %id% cmds\(dq | %PAGER%
 .EE
@@ -298,7 +311,7 @@ map sa :set sortby atime; set info atime
 map sc :set sortby ctime; set info ctime
 map se :set sortby ext; set info
 map gh cd \(ti
-map <space> :toggle; down
+nmap <space> :toggle; down
 .EE
 .PP
 If the \f[CR]mouse\f[R] option is enabled, mouse buttons have the
@@ -421,6 +434,18 @@ A count can be specified to move to a specific line, for example, use
 Move the current file selection to the high/middle/low of the screen.
 .SS toggle
 Toggle the selection of the current file or files given as arguments.
+.SS visual (modal) (default \(aqV\(aq)
+Switch to visual mode.
+If already in visual mode, discard visual selection and stay in visual
+mode.
+.SS visual\-accept (modal) (default \(aqV\(aq)
+Add the visual selection to the selection list, quit visual mode and
+return to normal mode.
+.SS visual\-discard (modal) (default \(aq\(aq)
+Discard the visual selection, quit visual mode and return to normal
+mode.
+.SS visual\-change (default \(aqo\(aq)
+Go to the other end of the current visual mode selection.
 .SS invert (default \f[CR]v\f[R])
 Reverse the selection of all files in the current directory (i.e.
 \f[CR]toggle\f[R] all files).
@@ -429,23 +454,6 @@ You can define a new command to select all files in the directory by
 combining \f[CR]invert\f[R] with \f[CR]unselect\f[R] (i.e.
 \f[CR]cmd select\-all :unselect; invert\f[R]), though this will also
 remove selections in other directories.
-.SS invert\-below
-Reverse the selection (i.e.
-\f[CR]toggle\f[R]) of all files at or after the current file in the
-current directory.
-.PP
-To select a contiguous block of files, use this command on the first
-file you want to select.
-Then, move down to the first file you do \f[I]not\f[R] want to select
-(the one after the end of the desired selection) and use this command
-again.
-This achieves an effect similar to the visual mode in Vim.
-.PP
-This command is experimental and may be removed once a better
-replacement for the visual mode is implemented in \f[CR]lf\f[R].
-If you\(aqd like to experiment with using this command, you should bind
-it to a key (e.g.
-\f[CR]V\f[R]) for a better experience.
 .SS unselect (default \f[CR]u\f[R])
 Remove the selection of all files in all directories.
 .SS glob\-select, glob\-unselect
@@ -457,7 +465,8 @@ Option \f[CR]info\f[R] should include \f[CR]size\f[R] and option
 If the total size of a directory is not calculated, it will be shown as
 \f[CR]\-\f[R].
 .SS clearmaps
-Remove all keybindings associated with the \f[CR]map\f[R] command.
+Remove all keybindings associated with the \f[CR]map\f[R],
+\f[CR]nmap\f[R] and \f[CR]vmap\f[R] command.
 This command can be used in the config file to remove the default
 keybindings.
 For safety purposes, \f[CR]:\f[R] is left mapped to the \f[CR]read\f[R]
@@ -868,17 +877,18 @@ Reverse the direction of sort.
 .SS roundbox (bool) (default false)
 Draw rounded outer corners when the \f[CR]drawbox\f[R] option is
 enabled.
-.SS rulerfmt (string) (default \f[CR]  %a|  %p|  \(rs033[7;31m %m \(rs033[0m|  \(rs033[7;33m %c \(rs033[0m|  \(rs033[7;35m %s \(rs033[0m|  \(rs033[7;34m %f \(rs033[0m|  %i/%t\f[R])
+.SS rulerfmt (string) (default \f[CR]  %a|  %p|  \(rs033[7;31m %m \(rs033[0m|  \(rs033[7;33m %c \(rs033[0m|  \(rs033[7;35m %s \(rs033[0m|  \(rs033[7;36m %v \(rs033[0m|  \(rs033[7;34m %f \(rs033[0m|  %i/%t\f[R])
 Format string of the ruler shown in the bottom right corner.
 Special expansions are provided, \f[CR]%a\f[R] as the pressed keys,
 \f[CR]%p\f[R] as the progress of file operations, \f[CR]%m\f[R] as the
 number of files to be cut (moved), \f[CR]%c\f[R] as the number of files
 to be copied, \f[CR]%s\f[R] as the number of selected files,
-\f[CR]%f\f[R] as the filter, \f[CR]%i\f[R] as the position of the
-cursor, \f[CR]%t\f[R] as the number of files shown in the current
-directory, \f[CR]%h\f[R] as the number of files hidden in the current
-directory, \f[CR]%P\f[R] as the scroll percentage, and \f[CR]%d\f[R] as
-the amount of free disk space remaining.
+\f[CR]%v\f[R] as the number of visually selected files, \f[CR]%f\f[R] as
+the filter, \f[CR]%i\f[R] as the position of the cursor, \f[CR]%t\f[R]
+as the number of files shown in the current directory, \f[CR]%h\f[R] as
+the number of files hidden in the current directory, \f[CR]%P\f[R] as
+the scroll percentage, and \f[CR]%d\f[R] as the amount of free disk
+space remaining.
 Additional expansions are provided for environment variables exported by
 lf, in the form \f[CR]%{lf_<name>}\f[R] (e.g.
 \f[CR]%{lf_selmode}\f[R]).
@@ -935,8 +945,10 @@ Special expansions are provided, \f[CR]%p\f[R] as the file permissions,
 \f[CR]%c\f[R] as the link count, \f[CR]%u\f[R] as the user,
 \f[CR]%g\f[R] as the group, \f[CR]%s\f[R] as the file size,
 \f[CR]%S\f[R] as the file size but with a fixed width of four characters
-(left\-padded with spaces), \f[CR]%t\f[R] as the last modified time, and
-\f[CR]%l\f[R] as the link target.
+(left\-padded with spaces), \f[CR]%t\f[R] as the last modified time,
+\f[CR]%l\f[R] as the link target, \f[CR]%m\f[R] as the current mode and
+\f[CR]%M\f[R] as the current mode but also shown in normal mode
+(displaying NORMAL instead of a blank string).
 The \f[CR]|\f[R] character splits the format string into sections.
 Any section containing a failed expansion (result is a blank string) is
 discarded and not shown.
@@ -982,6 +994,8 @@ value of 0 will only show the end of the filename, e.g.:
 \f[CR]very\-long\-f\(ti\-truncated\f[R]
 .IP \(bu 2
 \f[CR]set truncatepct 0\f[R] \-> \f[CR]\(ting\-filename\-truncated\f[R]
+.SS visualfmt (string) (default \f[CR]\(rs033[7;36m\f[R])
+Format string of the indicator for files that are visually selected.
 .SS waitmsg (string) (default \f[CR]Press any key to continue\f[R])
 String shown after commands of shell\-wait type.
 .SS watch (bool) (default false)
@@ -1066,7 +1080,8 @@ mode is.
 Possible values are \f[CR]delete\f[R], \f[CR]rename\f[R],
 \f[CR]filter\f[R], \f[CR]find\f[R], \f[CR]mark\f[R], \f[CR]search\f[R],
 \f[CR]command\f[R], \f[CR]shell\f[R], \f[CR]pipe\f[R] (when running a
-shell\-pipe command), \f[CR]normal\f[R], and \f[CR]unknown\f[R].
+shell\-pipe command), \f[CR]normal\f[R], \f[CR]visual\f[R] and
+\f[CR]unknown\f[R].
 .SH SPECIAL COMMANDS
 This section shows information about special shell commands.
 .SS open
@@ -1124,6 +1139,8 @@ file for reading shell commands.
 The difference is that prefixes are not necessary in the command line.
 Instead, different modes are provided to read corresponding commands.
 These modes are mapped to the prefix keys above by default.
+Visual mode mappings are defined the same way normal mode mappings are
+defined.
 .SH SYNTAX
 Characters from \f[CR]#\f[R] to newline are comments and ignored:
 .IP
@@ -1131,8 +1148,9 @@ Characters from \f[CR]#\f[R] to newline are comments and ignored:
 # comments start with \(ga#\(ga
 .EE
 .PP
-There are five special commands (\f[CR]set\f[R], \f[CR]setlocal\f[R],
-\f[CR]map\f[R], \f[CR]cmap\f[R], and \f[CR]cmd\f[R]) for configuration.
+The following commands (\f[CR]set\f[R], \f[CR]setlocal\f[R],
+\f[CR]map\f[R], \f[CR]nmap\f[R], \f[CR]vmap\f[R], \f[CR]cmap\f[R], and
+\f[CR]cmd\f[R]) are used for configuration.
 .PP
 Command \f[CR]set\f[R] is used to set an option which can be a boolean,
 integer, or string:
@@ -1172,8 +1190,9 @@ setlocal /foo/bar  hidden        # for only \(aq/foo/bar\(aq directory
 setlocal /foo/bar/ hidden        # for \(aq/foo/bar\(aq and its subdirectories (e.g. \(aq/foo/bar/baz\(aq)
 .EE
 .PP
-Command \(aqmap\(aq is used to bind a key to a command which can be a
-builtin command, custom command, or shell command:
+Command \f[CR]map\f[R] is used to bind a key in normal and visual mode
+to a command which can be a builtin command, custom command, or shell
+command:
 .IP
 .EX
 map gh cd \(ti        # builtin command
@@ -1182,7 +1201,20 @@ map i $less $f     # shell command
 map U !du \-csh *   # waiting shell command
 .EE
 .PP
-Command \(aqcmap\(aq is used to bind a key on the command line to a
+Command \f[CR]nmap\f[R] does the same but for normal mode only.
+.PP
+Command \f[CR]vmap\f[R] does the same but for visual mode only.
+.PP
+Overview of which map command works in which mode:
+.IP
+.EX
+map                Normal, Visual
+nmap               Normal
+vmap               Visual
+cmap               Command\-line
+.EE
+.PP
+Command \f[CR]cmap\f[R] is used to bind a key on the command line to a
 command line command or any other command:
 .IP
 .EX
@@ -1193,7 +1225,9 @@ cmap <a\-i> set incsearch!
 You can delete an existing binding by leaving the expression empty:
 .IP
 .EX
-map gh             # deletes \(aqgh\(aq mapping
+map gh             # deletes \(aqgh\(aq mapping in normal and visual mode
+nmap v             # deletes \(aqv\(aq mapping in normal mode
+vmap o             # deletes \(aqo\(aq mapping in visual mode
 cmap <c\-g>         # deletes \(aq<c\-g>\(aq mapping
 .EE
 .PP
@@ -1548,7 +1582,9 @@ lf \-remote \(dqquery $id maps\(dq
 The following types of information are supported:
 .IP
 .EX
-maps     list of mappings created by the \(aqmap\(aq command
+maps     list of mappings created by the \(aqmap\(aq, \(aqnmap\(aq and \(aqvmap\(aq command
+nmaps    list of mappings created by the \(aqnmap\(aq and \(aqmap\(aq command
+vmaps    list of mappings created by the \(aqvmap\(aq and \(aqmap\(aq command
 cmaps    list of mappings created by the \(aqcmap\(aq command
 cmds     list of commands created by the \(aqcmd\(aq command
 jumps    contents of the jump list, showing previously visited locations
@@ -1556,6 +1592,14 @@ history  list of previously executed commands on the command line
 files    list of files in the currently open directory as displayed by lf, empty if dir is still loading
 .EE
 .PP
+When listing mappings the characters in the first column are:
+.IP
+.EX
+n  Normal
+v  Visual
+c  Command\-line
+.EE
+.PP
 This is useful for scripting actions based on the internal state of lf.
 For example, to select a previous command using fzf and execute it:
 .IP
diff --git a/misc.go b/misc.go
index b1c2cfd..9ad82af 100644
--- a/misc.go
+++ b/misc.go
@@ -335,7 +335,7 @@ func getFileExtension(file fs.FileInfo) string {
 
 var (
 	reModKey    = regexp.MustCompile(`<(c|s|a)-(.+)>`)
-	reRulerSub  = regexp.MustCompile(`%[apmcsfithPd]|%\{[^}]+\}`)
+	reRulerSub  = regexp.MustCompile(`%[apmcsvfithPd]|%\{[^}]+\}`)
 	reSixelSize = regexp.MustCompile(`"1;1;(\d+);(\d+)`)
 )
 
diff --git a/nav.go b/nav.go
index 469e5c2..3d3686a 100644
--- a/nav.go
+++ b/nav.go
@@ -159,24 +159,25 @@ func readdir(path string) ([]*file, error) {
 }
 
 type dir struct {
-	loading     bool       // directory is loading from disk
-	loadTime    time.Time  // current loading or last load time
-	ind         int        // index of current entry in files
-	pos         int        // position of current entry in ui
-	path        string     // full path of directory
-	files       []*file    // displayed files in directory including or excluding hidden ones
-	allFiles    []*file    // all files in directory including hidden ones (same array as files)
-	sortby      sortMethod // sortby value from last sort
-	dirfirst    bool       // dirfirst value from last sort
-	dironly     bool       // dironly value from last sort
-	hidden      bool       // hidden value from last sort
-	reverse     bool       // reverse value from last sort
-	hiddenfiles []string   // hiddenfiles value from last sort
-	filter      []string   // last filter for this directory
-	ignorecase  bool       // ignorecase value from last sort
-	ignoredia   bool       // ignoredia value from last sort
-	locale      string     // locale value from last sort
-	noPerm      bool       // whether lf has no permission to open the directory
+	loading      bool       // directory is loading from disk
+	loadTime     time.Time  // current loading or last load time
+	ind          int        // index of current entry in files
+	pos          int        // position of current entry in ui
+	path         string     // full path of directory
+	files        []*file    // displayed files in directory including or excluding hidden ones
+	allFiles     []*file    // all files in directory including hidden ones (same array as files)
+	sortby       sortMethod // sortby value from last sort
+	dirfirst     bool       // dirfirst value from last sort
+	dironly      bool       // dironly value from last sort
+	hidden       bool       // hidden value from last sort
+	reverse      bool       // reverse value from last sort
+	visualAnchor int        // index where visual mode was initiated
+	hiddenfiles  []string   // hiddenfiles value from last sort
+	filter       []string   // last filter for this directory
+	ignorecase   bool       // ignorecase value from last sort
+	ignoredia    bool       // ignoredia value from last sort
+	locale       string     // locale value from last sort
+	noPerm       bool       // whether lf has no permission to open the directory
 }
 
 func newDir(path string) *dir {
@@ -188,11 +189,12 @@ func newDir(path string) *dir {
 	}
 
 	return &dir{
-		loadTime: time,
-		path:     path,
-		files:    files,
-		allFiles: files,
-		noPerm:   os.IsPermission(err),
+		loadTime:     time,
+		path:         path,
+		files:        files,
+		allFiles:     files,
+		visualAnchor: -1,
+		noPerm:       os.IsPermission(err),
 	}
 }
 
@@ -420,6 +422,28 @@ func (d *dir) fileNames() []string {
 	return names
 }
 
+func (nav *nav) isVisualMode() bool {
+	return nav.init && nav.currDir().visualAnchor != -1
+}
+
+func (dir *dir) visualSelections() []string {
+	paths := []string{}
+	if dir.visualAnchor == -1 || len(dir.files) == 0 {
+		return paths
+	}
+
+	beg, end := dir.ind, dir.visualAnchor
+	if beg > end {
+		beg, end = end, beg
+	}
+
+	for _, f := range dir.files[beg : end+1] {
+		paths = append(paths, f.path)
+	}
+
+	return paths
+}
+
 func (dir *dir) sel(name string, height int) {
 	if len(dir.files) == 0 {
 		dir.ind, dir.pos = 0, 0
@@ -510,18 +534,19 @@ type nav struct {
 
 func (nav *nav) loadDirInternal(path string) *dir {
 	d := &dir{
-		loading:     true,
-		loadTime:    time.Now(),
-		path:        path,
-		sortby:      getSortBy(path),
-		dirfirst:    getDirFirst(path),
-		dironly:     getDirOnly(path),
-		hidden:      getHidden(path),
-		reverse:     getReverse(path),
-		locale:      getLocale(path),
-		hiddenfiles: gOpts.hiddenfiles,
-		ignorecase:  gOpts.ignorecase,
-		ignoredia:   gOpts.ignoredia,
+		loading:      true,
+		loadTime:     time.Now(),
+		path:         path,
+		sortby:       getSortBy(path),
+		dirfirst:     getDirFirst(path),
+		dironly:      getDirOnly(path),
+		hidden:       getHidden(path),
+		reverse:      getReverse(path),
+		locale:       getLocale(path),
+		visualAnchor: -1,
+		hiddenfiles:  gOpts.hiddenfiles,
+		ignorecase:   gOpts.ignorecase,
+		ignoredia:    gOpts.ignoredia,
 	}
 	go func() {
 		d := newDir(path)
@@ -1236,22 +1261,14 @@ func (nav *nav) tag(tag string) error {
 	return nil
 }
 
-func (nav *nav) invertAfter(ix int) {
+func (nav *nav) invert() {
 	dir := nav.currDir()
-	for _, f := range dir.files[ix:] {
+	for _, f := range dir.files {
 		path := filepath.Join(dir.path, f.Name())
 		nav.toggleSelection(path)
 	}
 }
 
-func (nav *nav) invert() {
-	nav.invertAfter(0)
-}
-
-func (nav *nav) invertBelow() {
-	nav.invertAfter(nav.currDir().ind)
-}
-
 func (nav *nav) unselect() {
 	clear(nav.selections)
 	nav.selectionInd = 0
diff --git a/opts.go b/opts.go
index 287c130..3333568 100644
--- a/opts.go
+++ b/opts.go
@@ -1,6 +1,7 @@
 package main
 
 import (
+	"maps"
 	"path/filepath"
 	"time"
 )
@@ -64,6 +65,7 @@ var gOpts struct {
 	reverse          bool
 	roundbox         bool
 	selectfmt        string
+	visualfmt        string
 	showbinds        bool
 	sixel            bool
 	sortby           sortMethod
@@ -99,7 +101,8 @@ var gOpts struct {
 	rulerfmt         string
 	preserve         []string
 	shellopts        []string
-	keys             map[string]expr
+	nkeys            map[string]expr
+	vkeys            map[string]expr
 	cmdkeys          map[string]expr
 	cmds             map[string]expr
 	user             map[string]string
@@ -231,6 +234,7 @@ func init() {
 	gOpts.reverse = false
 	gOpts.roundbox = false
 	gOpts.selectfmt = "\033[7;35m"
+	gOpts.visualfmt = "\033[7;36m"
 	gOpts.showbinds = true
 	gOpts.sixel = false
 	gOpts.sortby = naturalSort
@@ -263,14 +267,15 @@ func init() {
 	gOpts.hiddenfiles = gDefaultHiddenFiles
 	gOpts.history = true
 	gOpts.info = nil
-	gOpts.rulerfmt = "  %a|  %p|  \033[7;31m %m \033[0m|  \033[7;33m %c \033[0m|  \033[7;35m %s \033[0m|  \033[7;34m %f \033[0m|  %i/%t"
+	gOpts.rulerfmt = "  %a|  %p|  \033[7;31m %m \033[0m|  \033[7;33m %c \033[0m|  \033[7;35m %s \033[0m|  \033[7;36m %v \033[0m|  \033[7;34m %f \033[0m|  %i/%t"
 	gOpts.preserve = []string{"mode"}
 	gOpts.shellopts = nil
 	gOpts.tempmarks = "'"
 	gOpts.numberfmt = "\033[33m"
 	gOpts.tagfmt = "\033[31m"
 
-	gOpts.keys = map[string]expr{
+	// normal and visual mode
+	keys := map[string]expr{
 		"k":          &callExpr{"up", nil, 1},
 		"<up>":       &callExpr{"up", nil, 1},
 		"<m-up>":     &callExpr{"up", nil, 1},
@@ -301,9 +306,7 @@ func init() {
 		"L":          &callExpr{"low", nil, 1},
 		"[":          &callExpr{"jump-prev", nil, 1},
 		"]":          &callExpr{"jump-next", nil, 1},
-		"<space>":    &listExpr{[]expr{&callExpr{"toggle", nil, 1}, &callExpr{"down", nil, 1}}, 1},
 		"t":          &callExpr{"tag-toggle", nil, 1},
-		"v":          &callExpr{"invert", nil, 1},
 		"u":          &callExpr{"unselect", nil, 1},
 		"y":          &callExpr{"copy", nil, 1},
 		"d":          &callExpr{"cut", nil, 1},
@@ -346,6 +349,20 @@ func init() {
 		"gh": &callExpr{"cd", []string{"~"}, 1},
 	}
 
+	// insert bindings that apply to both normal & visual mode first
+	gOpts.nkeys = maps.Clone(keys)
+	// now add normal mode specific ones
+	gOpts.nkeys["<space>"] = &listExpr{[]expr{&callExpr{"toggle", nil, 1}, &callExpr{"down", nil, 1}}, 1}
+	gOpts.nkeys["V"] = &callExpr{"visual", nil, 1}
+	gOpts.nkeys["v"] = &callExpr{"invert", nil, 1}
+
+	// now do the same for visual mode
+	gOpts.vkeys = maps.Clone(keys)
+	gOpts.vkeys["<esc>"] = &callExpr{"visual-discard", nil, 1}
+	gOpts.vkeys["V"] = &callExpr{"visual-accept", nil, 1}
+	gOpts.vkeys["o"] = &callExpr{"visual-change", nil, 1}
+
+	// command line mode bindings can be assigned directly
 	gOpts.cmdkeys = map[string]expr{
 		"<space>":        &callExpr{"cmd-insert", []string{" "}, 1},
 		"<esc>":          &callExpr{"cmd-escape", nil, 1},
diff --git a/os.go b/os.go
index ff3669e..31899e3 100644
--- a/os.go
+++ b/os.go
@@ -157,14 +157,20 @@ func shellKill(cmd *exec.Cmd) error {
 
 func setDefaults() {
 	gOpts.cmds["open"] = &execExpr{"&", `$OPENER "$f"`}
-	gOpts.keys["e"] = &execExpr{"$", `$EDITOR "$f"`}
-	gOpts.keys["i"] = &execExpr{"$", `$PAGER "$f"`}
-	gOpts.keys["w"] = &execExpr{"$", "$SHELL"}
+	gOpts.nkeys["e"] = &execExpr{"$", `$EDITOR "$f"`}
+	gOpts.vkeys["e"] = &execExpr{"$", `$EDITOR "$f"`}
+	gOpts.nkeys["i"] = &execExpr{"$", `$PAGER "$f"`}
+	gOpts.vkeys["i"] = &execExpr{"$", `$PAGER "$f"`}
+	gOpts.nkeys["w"] = &execExpr{"$", "$SHELL"}
+	gOpts.vkeys["w"] = &execExpr{"$", "$SHELL"}
 
 	gOpts.cmds["doc"] = &execExpr{"$", `"$lf" -doc | $PAGER`}
-	gOpts.keys["<f-1>"] = &callExpr{"doc", nil, 1}
+	gOpts.nkeys["<f-1>"] = &callExpr{"doc", nil, 1}
+	gOpts.vkeys["<f-1>"] = &callExpr{"doc", nil, 1}
 
 	gOpts.cmds["maps"] = &execExpr{"$", `"$lf" -remote "query $id maps" | $PAGER`}
+	gOpts.cmds["nmaps"] = &execExpr{"$", `"$lf" -remote "query $id nmaps" | $PAGER`}
+	gOpts.cmds["vmaps"] = &execExpr{"$", `"$lf" -remote "query $id vmaps" | $PAGER`}
 	gOpts.cmds["cmaps"] = &execExpr{"$", `"$lf" -remote "query $id cmaps" | $PAGER`}
 	gOpts.cmds["cmds"] = &execExpr{"$", `"$lf" -remote "query $id cmds" | $PAGER`}
 }
diff --git a/os_windows.go b/os_windows.go
index dac8f44..c5f5d41 100644
--- a/os_windows.go
+++ b/os_windows.go
@@ -143,14 +143,20 @@ func shellKill(cmd *exec.Cmd) error {
 
 func setDefaults() {
 	gOpts.cmds["open"] = &execExpr{"&", "%OPENER% %f%"}
-	gOpts.keys["e"] = &execExpr{"$", "%EDITOR% %f%"}
-	gOpts.keys["i"] = &execExpr{"!", "%PAGER% %f%"}
-	gOpts.keys["w"] = &execExpr{"$", "%SHELL%"}
+	gOpts.nkeys["e"] = &execExpr{"$", "%EDITOR% %f%"}
+	gOpts.vkeys["e"] = &execExpr{"$", "%EDITOR% %f%"}
+	gOpts.nkeys["i"] = &execExpr{"!", "%PAGER% %f%"}
+	gOpts.vkeys["i"] = &execExpr{"!", "%PAGER% %f%"}
+	gOpts.nkeys["w"] = &execExpr{"$", "%SHELL%"}
+	gOpts.vkeys["w"] = &execExpr{"$", "%SHELL%"}
 
 	gOpts.cmds["doc"] = &execExpr{"!", "%lf% -doc | %PAGER%"}
-	gOpts.keys["<f-1>"] = &callExpr{"doc", nil, 1}
+	gOpts.nkeys["<f-1>"] = &callExpr{"doc", nil, 1}
+	gOpts.vkeys["<f-1>"] = &callExpr{"doc", nil, 1}
 
 	gOpts.cmds["maps"] = &execExpr{"!", `%lf% -remote "query %id% maps" | %PAGER%`}
+	gOpts.cmds["nmaps"] = &execExpr{"!", `%lf% -remote "query %id% nmaps" | %PAGER%`}
+	gOpts.cmds["vmaps"] = &execExpr{"!", `%lf% -remote "query %id% vmaps" | %PAGER%`}
 	gOpts.cmds["cmaps"] = &execExpr{"!", `%lf% -remote "query %id% cmaps" | %PAGER%`}
 	gOpts.cmds["cmds"] = &execExpr{"!", `%lf% -remote "query %id% cmds" | %PAGER%`}
 }
diff --git a/parse.go b/parse.go
index ceb3fde..88ce1a9 100644
--- a/parse.go
+++ b/parse.go
@@ -5,6 +5,8 @@ package main
 // Expr         = SetExpr
 //              | SetLocalExpr
 //              | MapExpr
+//              | NMapExpr
+//              | VMapExpr
 //              | CMapExpr
 //              | CmdExpr
 //              | CallExpr
@@ -17,6 +19,10 @@ package main
 //
 // MapExpr      = 'map' <keys> Expr
 //
+// NMapExpr     = 'nmap' <keys> Expr
+//
+// VMapExpr     = 'vmap' <keys> Expr
+//
 // CMapExpr     = 'cmap' <key> Expr
 //
 // CmdExpr      = 'cmd' <name> Expr
@@ -68,6 +74,20 @@ type mapExpr struct {
 
 func (e *mapExpr) String() string { return fmt.Sprintf("map %s %s", e.keys, e.expr) }
 
+type nmapExpr struct {
+	keys string
+	expr expr
+}
+
+func (e *nmapExpr) String() string { return fmt.Sprintf("nmap %s %s", e.keys, e.expr) }
+
+type vmapExpr struct {
+	keys string
+	expr expr
+}
+
+func (e *vmapExpr) String() string { return fmt.Sprintf("vmap %s %s", e.keys, e.expr) }
+
 type cmapExpr struct {
 	key  string
 	expr expr
@@ -225,6 +245,34 @@ func (p *parser) parseExpr() expr {
 			}
 
 			result = &mapExpr{keys, expr}
+		case "nmap":
+			var expr expr
+
+			s.scan()
+			keys := s.tok
+
+			s.scan()
+			if s.typ != tokenSemicolon {
+				expr = p.parseExpr()
+			} else {
+				s.scan()
+			}
+
+			result = &nmapExpr{keys, expr}
+		case "vmap":
+			var expr expr
+
+			s.scan()
+			keys := s.tok
+
+			s.scan()
+			if s.typ != tokenSemicolon {
+				expr = p.parseExpr()
+			} else {
+				s.scan()
+			}
+
+			result = &vmapExpr{keys, expr}
 		case "cmap":
 			var expr expr
 
diff --git a/ui.go b/ui.go
index b913d83..e181af5 100644
--- a/ui.go
+++ b/ui.go
@@ -6,6 +6,7 @@ import (
 	"log"
 	"os"
 	"path/filepath"
+	"slices"
 	"sort"
 	"strconv"
 	"strings"
@@ -425,6 +426,7 @@ func (win *win) printDir(ui *ui, dir *dir, context *dirContext, dirStyle *dirSty
 		}
 	}
 
+	visualSelections := dir.visualSelections()
 	for i, f := range dir.files[beg:end] {
 		st := dirStyle.colors.get(f)
 
@@ -451,7 +453,9 @@ func (win *win) printDir(ui *ui, dir *dir, context *dirContext, dirStyle *dirSty
 
 		path := filepath.Join(dir.path, f.Name())
 
-		if _, ok := context.selections[path]; ok {
+		if slices.Contains(visualSelections, path) {
+			win.print(ui.screen, lnwidth, i, parseEscapeSequence(gOpts.visualfmt), " ")
+		} else if _, ok := context.selections[path]; ok {
 			win.print(ui.screen, lnwidth, i, parseEscapeSequence(gOpts.selectfmt), " ")
 		} else if cp, ok := context.saves[path]; ok {
 			if cp {
@@ -820,6 +824,13 @@ func (ui *ui) loadFileInfo(nav *nav) {
 		}
 		statfmt = strings.ReplaceAll(statfmt, s, val)
 	}
+	if nav.isVisualMode() {
+		replace("%m", "VISUAL")
+		replace("%M", "VISUAL")
+	} else {
+		replace("%m", "")
+		replace("%M", "NORMAL")
+	}
 	replace("%p", curr.Mode().String())
 	replace("%c", linkCount(curr))
 	replace("%u", userName(curr))
@@ -948,6 +959,7 @@ func (ui *ui) drawRuler(nav *nav) {
 	}
 
 	currSelections := nav.currSelections()
+	currVSelections := nav.currDir().visualSelections()
 
 	progress := []string{}
 
@@ -979,6 +991,8 @@ func (ui *ui) drawRuler(nav *nav) {
 			result = fmt.Sprintf("%.d", copy)
 		case "%s":
 			result = fmt.Sprintf("%.d", len(currSelections))
+		case "%v":
+			result = fmt.Sprintf("%.d", len(currVSelections))
 		case "%f":
 			result = strings.Join(dir.filter, " ")
 		case "%i":
@@ -1159,32 +1173,68 @@ func findBinds(keys map[string]expr, prefix string) (binds map[string]expr, ok b
 	return
 }
 
-func listExprMap(binds map[string]expr, title string) string {
+func listBinds(binds map[string]map[string]expr) string {
 	t := new(tabwriter.Writer)
 	b := new(bytes.Buffer)
 
-	keys := make([]string, 0, len(binds))
-	for k := range binds {
-		keys = append(keys, k)
+	m := make(map[string]map[string]string)
+	for mode, keys := range binds {
+		for key, expr := range keys {
+			if _, ok := m[key]; !ok {
+				m[key] = make(map[string]string)
+			}
+			m[key][expr.String()] += mode
+		}
 	}
-	sort.Strings(keys)
+
+	type entry struct {
+		mode, key, cmd string
+	}
+
+	var entries []entry
+	for key, cmds := range m {
+		for cmd, modes := range cmds {
+			tmp := []rune(modes)
+			slices.Sort(tmp)
+			entries = append(entries, entry{string(tmp), key, cmd})
+		}
+	}
+
+	sort.Slice(entries, func(i, j int) bool {
+		if entries[i].key != entries[j].key {
+			return entries[i].key < entries[j].key
+		}
+		return entries[i].mode < entries[j].mode
+	})
 
 	t.Init(b, 0, gOpts.tabstop, 2, '\t', 0)
-	fmt.Fprintf(t, "%s\tcommand\n", title)
-	for _, k := range keys {
-		fmt.Fprintf(t, "%s\t%v\n", k, binds[k])
+	fmt.Fprintln(t, "mode\tkeys\tcommand")
+	for _, e := range entries {
+		fmt.Fprintf(t, "%s\t%s\t%s\n", e.mode, e.key, e.cmd)
 	}
 	t.Flush()
 
 	return b.String()
 }
 
-func listBinds(binds map[string]expr) string {
-	return listExprMap(binds, "keys")
-}
+func listCmds(cmds map[string]expr) string {
+	t := new(tabwriter.Writer)
+	b := new(bytes.Buffer)
 
-func listCmds() string {
-	return listExprMap(gOpts.cmds, "name")
+	keys := make([]string, 0, len(cmds))
+	for k := range cmds {
+		keys = append(keys, k)
+	}
+	sort.Strings(keys)
+
+	t.Init(b, 0, gOpts.tabstop, 2, '\t', 0)
+	fmt.Fprintln(t, "name\tcommand")
+	for _, k := range keys {
+		fmt.Fprintf(t, "%s\t%v\n", k, cmds[k])
+	}
+	t.Flush()
+
+	return b.String()
 }
 
 func listJumps(jumps []string, ind int) string {
@@ -1337,6 +1387,13 @@ func (ui *ui) readNormalEvent(ev tcell.Event, nav *nav) expr {
 	draw := &callExpr{"draw", nil, 1}
 	count := 0
 
+	keys := gOpts.nkeys
+	mode := "n"
+	if nav.isVisualMode() {
+		keys = gOpts.vkeys
+		mode = "v"
+	}
+
 	switch tev := ev.(type) {
 	case *tcell.EventKey:
 		// KeyRune is a regular character
@@ -1371,7 +1428,7 @@ func (ui *ui) readNormalEvent(ev tcell.Event, nav *nav) expr {
 			return draw
 		}
 
-		binds, ok := findBinds(gOpts.keys, string(ui.keyAcc))
+		binds, ok := findBinds(keys, string(ui.keyAcc))
 
 		switch len(binds) {
 		case 0:
@@ -1389,7 +1446,7 @@ func (ui *ui) readNormalEvent(ev tcell.Event, nav *nav) expr {
 					}
 					count = c
 				}
-				expr := gOpts.keys[string(ui.keyAcc)]
+				expr := keys[string(ui.keyAcc)]
 
 				if count != 0 {
 					switch e := expr.(type) {
@@ -1406,7 +1463,9 @@ func (ui *ui) readNormalEvent(ev tcell.Event, nav *nav) expr {
 				return expr
 			}
 			if gOpts.showbinds {
-				ui.menu = listBinds(binds)
+				ui.menu = listBinds(map[string]map[string]expr{
+					mode: binds,
+				})
 			}
 			return draw
 		}
@@ -1448,7 +1507,7 @@ func (ui *ui) readNormalEvent(ev tcell.Event, nav *nav) expr {
 		if tev.Modifiers() == tcell.ModCtrl {
 			button = "<c-" + button[1:]
 		}
-		if expr, ok := gOpts.keys[button]; ok {
+		if expr, ok := keys[button]; ok {
 			return expr
 		}
 		if button != "<m-1>" && button != "<m-2>" {
@@ -1577,43 +1636,6 @@ 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/eval_test.go b/eval_test.go
index 3ddf03e..86ee256 100644
--- a/eval_test.go
+++ b/eval_test.go
@@ -294,6 +294,18 @@ var gEvalTests = []struct {
 		[]expr{&mapExpr{"gh", &listExpr{[]expr{&callExpr{"cd", []string{"~"}, 1}}, 1}}},
 	},
 
+	{
+		"nmap <space> :toggle; down",
+		[]string{"nmap", "<space>", ":", "toggle", ";", "down", "\n", "\n"},
+		[]expr{&nmapExpr{"<space>", &listExpr{[]expr{&callExpr{"toggle", nil, 1}, &callExpr{"down", nil, 1}}, 1}}},
+	},
+
+	{
+		"vmap <esc> visual-accept",
+		[]string{"vmap", "<esc>", "visual-accept", "\n"},
+		[]expr{&vmapExpr{"<esc>", &callExpr{"visual-accept", nil, 1}}},
+	},
+
 	{
 		"cmap <c-g> cmd-escape",
 		[]string{"cmap", "<c-g>", "cmd-escape", "\n"},