package main

import (
	"bytes"
	"cmp"
	"fmt"
	"log"
	"maps"
	"os"
	"path/filepath"
	"reflect"
	"slices"
	"strconv"
	"strings"
	"text/tabwriter"
	"text/template"
	"time"
	"unicode/utf8"

	"github.com/clipperhouse/displaywidth"
	"github.com/gdamore/tcell/v3"
	"golang.org/x/term"
)

const previewLoadingDelay = 100 * time.Millisecond

type win struct {
	w, h, x, y int
}

func newWin(w, h, x, y int) *win {
	return &win{w, h, x, y}
}

func (win *win) renew(w, h, x, y int) {
	win.w, win.h, win.x, win.y = w, h, x, y
}

// firstGrapheme returns the first grapheme cluster in s and its display width.
func firstGrapheme(s string) (string, int) {
	gr := displaywidth.StringGraphemes(s)
	if !gr.Next() {
		return "", 0
	}
	return gr.Value(), gr.Width()
}

// printLength returns the display width of s in terminal cells.
//
// It ignores supported terminal control sequences (see [readTermSequence])
// and accounts for tab expansions using the `tabstop` option.
func printLength(s string) int {
	length := 0

	slen := len(s)
	for i := 0; i < slen; {
		seq := readTermSequence(s[i:])
		if seq != "" {
			i += len(seq)
			continue
		}

		gc, w := firstGrapheme(s[i:])
		i += len(gc)

		if gc == "\t" {
			length += gOpts.tabstop - length%gOpts.tabstop
		} else if isPrintable(gc) {
			length += w
		} else {
			length++ // U+FFFD replacement has width 1
		}
	}

	return length
}

func (win *win) print(screen tcell.Screen, x, y int, st tcell.Style, s string) tcell.Style {
	var b strings.Builder
	off := 0
	put := func() {
		if b.Len() > 0 {
			s := b.String()
			screen.PutStrStyled(win.x+x+off, win.y+y, s, st)
			off += printLength(s)
			b.Reset()
		}
	}

	slen := len(s)
	for i := 0; i < slen; {
		seq := readTermSequence(s[i:])
		if seq != "" {
			put()
			st = applyTermSequence(seq, st)
			i += len(seq)
			continue
		}

		gc := firstGraphemeCluster(s[i:])
		if gc == "\t" {
			w := gOpts.tabstop - (x+off+printLength(b.String()))%gOpts.tabstop
			b.WriteString(strings.Repeat(" ", w))
		} else if isPrintable(gc) {
			b.WriteString(gc)
		} else {
			b.WriteString("\uFFFD")
		}

		i += len(gc)
	}

	put()
	return st
}

func (win *win) printf(screen tcell.Screen, x, y int, st tcell.Style, format string, a ...any) {
	win.print(screen, x, y, st, fmt.Sprintf(format, a...))
}

func (win *win) printLine(screen tcell.Screen, x, y int, st tcell.Style, s string) {
	win.printf(screen, x, y, st, "%s%*s", s, win.w-printLength(s), "")
}

func (win *win) printRight(screen tcell.Screen, y int, st tcell.Style, s string) {
	win.print(screen, win.w-printLength(s), y, st, s)
}

func (win *win) printMsg(screen tcell.Screen, s string) {
	pad := 1
	if gOpts.mergeindicators {
		pad--
	}
	st := tcell.StyleDefault.Reverse(true)
	win.print(screen, pad, 0, st, s)
}

func (win *win) printReg(screen tcell.Screen, reg *reg, sxs *sixelScreen, previewTimer *time.Timer) {
	switch {
	case reg.loading:
		if time.Since(reg.loadTime) > previewLoadingDelay {
			win.printMsg(screen, "loading...")
		} else {
			previewTimer.Reset(previewLoadingDelay)
		}
	case reg.sixel:
		sxs.printSixel(win, screen, reg)
	default:
		st := tcell.StyleDefault
		for i, l := range reg.lines {
			if i > win.h-1 {
				break
			}

			st = win.print(screen, 0, i, st, l)
		}
	}

	if !reg.sixel {
		sxs.lastFile = ""
	}
}

var gThisYear = time.Now().Year()

func infotimefmt(t time.Time) string {
	if t.Year() == gThisYear {
		return t.Format(gOpts.infotimefmtnew)
	}
	return t.Format(gOpts.infotimefmtold)
}

func fileInfo(f *file, d *dir, userWidth, groupWidth, customWidth int) (string, string, int) {
	var info strings.Builder
	var custom string
	var off int

	for _, s := range getInfo(d.path) {
		switch s {
		case "size":
			if f.IsDir() && getDirCounts(d.path) {
				switch {
				case f.dirCount < 0:
					info.WriteString("     !")
				case f.dirCount < 10000:
					fmt.Fprintf(&info, " %5d", f.dirCount)
				default:
					info.WriteString(" 9999+")
				}
			} else {
				switch {
				case f.dirSize >= 0:
					fmt.Fprintf(&info, " %5s", humanize(f.dirSize))
				case f.IsDir():
					info.WriteString("     -")
				default:
					fmt.Fprintf(&info, " %5s", humanize(f.Size()))
				}
			}
		case "time":
			fmt.Fprintf(&info, " %*s", max(len(gOpts.infotimefmtnew), len(gOpts.infotimefmtold)), infotimefmt(f.ModTime()))
		case "atime":
			fmt.Fprintf(&info, " %*s", max(len(gOpts.infotimefmtnew), len(gOpts.infotimefmtold)), infotimefmt(f.accessTime))
		case "btime":
			fmt.Fprintf(&info, " %*s", max(len(gOpts.infotimefmtnew), len(gOpts.infotimefmtold)), infotimefmt(f.birthTime))
		case "ctime":
			fmt.Fprintf(&info, " %*s", max(len(gOpts.infotimefmtnew), len(gOpts.infotimefmtold)), infotimefmt(f.changeTime))
		case "perm":
			info.WriteString(" " + permString(f.Mode()))
		case "user":
			fmt.Fprintf(&info, " %-*s", userWidth, userName(f.FileInfo))
		case "group":
			fmt.Fprintf(&info, " %-*s", groupWidth, groupName(f.FileInfo))
		case "custom":
			// Prevent useless spacers, as `custom` allows empty values
			if customWidth < 1 {
				continue
			}
			// To allow for the usage of escape sequences, store `custom`
			// separately and print it later using the offset.
			off = info.Len()
			fmt.Fprintf(&info, " %*s", customWidth, "")
			custom = fmt.Sprintf(" %s%*s", f.customInfo, customWidth-printLength(f.customInfo), "")
		default:
			log.Printf("unknown info type: %s", s)
		}
	}

	return info.String(), custom, off
}

type dirContext struct {
	selections map[string]int
	clipboard  clipboard
	tags       map[string]string
}

// dirRole describes what kind of directory pane is being drawn.
type dirRole byte

const (
	Active  dirRole = iota // Current directory pane.
	Parent                 // Parent or ancestor directory pane.
	Preview                // Preview pane when it shows a directory listing.
)

type dirStyle struct {
	colors styleMap
	icons  iconMap
	role   dirRole
}

func (win *win) printDir(ui *ui, dir *dir, context *dirContext, dirStyle *dirStyle, previewTimer *time.Timer) {
	if win.w < 5 || dir == nil {
		return
	}

	fileslen := len(dir.files)

	switch {
	case dir.loading && fileslen == 0:
		if time.Since(dir.loadTime) > previewLoadingDelay {
			win.printMsg(ui.screen, "loading...")
		} else {
			previewTimer.Reset(previewLoadingDelay)
		}
		return
	case dir.noPerm:
		win.printMsg(ui.screen, "permission denied")
		return
	case fileslen == 0:
		win.printMsg(ui.screen, "empty")
		return
	}

	beg := max(dir.ind-dir.pos, 0)
	end := min(beg+win.h, fileslen)

	if beg > end {
		return
	}

	var lnwidth int

	if dirStyle.role == Active && (gOpts.number || gOpts.relativenumber) {
		lnwidth = 1
		for j := 10; j <= fileslen; j *= 10 {
			lnwidth++
		}
		if gOpts.number && gOpts.relativenumber {
			lnwidth = max(lnwidth, 2)
		}
	}

	var userWidth, groupWidth, customWidth int
	var fetchedCustom bool

	// Only fetch user/group/custom widths if configured to display them
	for _, s := range getInfo(dir.path) {
		switch s {
		case "user":
			userWidth = getUserWidth(dir, beg, end)
		case "group":
			groupWidth = getGroupWidth(dir, beg, end)
		case "custom":
			customWidth = getCustomWidth(dir, beg, end)
			fetchedCustom = true // Can have a length of 0
		}

		if userWidth > 0 && groupWidth > 0 && fetchedCustom {
			break
		}
	}

	indOff, tagOff, nameOff := lnwidth, lnwidth, lnwidth+1
	if !gOpts.mergeindicators {
		tagOff++
		nameOff++
	}

	visualSelections := dir.visualSelections()
	for i, f := range dir.files[beg:end] {
		st := dirStyle.colors.get(f)

		if lnwidth > 0 {
			var ln string

			if gOpts.number && !gOpts.relativenumber {
				ln = fmt.Sprintf("%*d", lnwidth, i+1+beg)
			} else if gOpts.relativenumber {
				switch {
				case i < dir.pos:
					ln = fmt.Sprintf("%*d", lnwidth, dir.pos-i)
				case i > dir.pos:
					ln = fmt.Sprintf("%*d", lnwidth, i-dir.pos)
				case gOpts.number:
					ln = fmt.Sprintf("%-*d", lnwidth, i+1+beg)
				default:
					ln = fmt.Sprintf("%*d", lnwidth, 0)
				}
			}

			fmtStr := optionToFmtstr(gOpts.numberfmt)
			if i == dir.pos && gOpts.numbercursorfmt != "" {
				fmtStr = optionToFmtstr(gOpts.numbercursorfmt)
			}
			win.print(ui.screen, 0, i, tcell.StyleDefault, fmt.Sprintf(fmtStr, ln))
		}

		path := filepath.Join(dir.path, f.Name())

		var fmtStr string
		if slices.Contains(visualSelections, path) {
			fmtStr = gOpts.visualfmt
		} else if _, ok := context.selections[path]; ok {
			fmtStr = gOpts.selectfmt
		} else if slices.Contains(context.clipboard.paths, path) {
			if context.clipboard.mode == clipboardCopy {
				fmtStr = gOpts.copyfmt
			} else {
				fmtStr = gOpts.cutfmt
			}
		}

		tag := " "
		if val, ok := context.tags[path]; ok && len(val) > 0 {
			tag = val
		}

		if fmtStr != "" {
			ind := " "
			if gOpts.mergeindicators {
				ind = tag
			}
			win.print(ui.screen, indOff, i, parseEscapeSequence(fmtStr), ind)
		}

		// make space for select marker, and leave another space at the end
		maxWidth := win.w - lnwidth - 2

		var icon string
		var iconDef iconDef
		if gOpts.icons {
			iconDef = dirStyle.icons.get(f)
			icon = iconDef.icon + " "
		}

		// subtract space for icon
		maxFilenameWidth := maxWidth - displaywidth.String(icon)
		// subtract space for tag if not merged with selection marker
		if !gOpts.mergeindicators {
			maxFilenameWidth--
		}

		info, custom, customOff := fileInfo(f, dir, userWidth, groupWidth, customWidth)
		infolen := len(info)
		showInfo := infolen > 0 && 2*infolen < maxWidth
		if showInfo {
			maxFilenameWidth -= infolen
		}

		filename := truncateFilename(f, maxFilenameWidth, gOpts.truncatepct, gOpts.truncatechar)
		spacing := maxFilenameWidth - displaywidth.String(filename)
		if spacing > 0 {
			filename += strings.Repeat(" ", spacing)
		}

		if showInfo {
			filename += info
			customOff += nameOff + displaywidth.String(icon) + maxFilenameWidth
		}

		if i == dir.pos {
			var cursorFmt string
			switch dirStyle.role {
			case Active:
				cursorFmt = optionToFmtstr(gOpts.cursoractivefmt)
			case Parent:
				cursorFmt = optionToFmtstr(gOpts.cursorparentfmt)
			case Preview:
				cursorFmt = optionToFmtstr(gOpts.cursorpreviewfmt)
			}

			// print tag separately as it can contain color escape sequences
			if !gOpts.mergeindicators || fmtStr == "" {
				win.print(ui.screen, tagOff, i, st, fmt.Sprintf(cursorFmt, tag))
			}

			win.print(ui.screen, nameOff, i, st, fmt.Sprintf(cursorFmt, icon+filename+" "))

			// print over the empty space we reserved for the custom info
			if showInfo && custom != "" {
				win.print(ui.screen, customOff, i, st, fmt.Sprintf(cursorFmt, stripTermSequence(custom)))
			}
		} else {
			if !gOpts.mergeindicators || fmtStr == "" {
				if tag == " " {
					win.print(ui.screen, tagOff, i, st, " ")
				} else {
					tagStr := fmt.Sprintf(optionToFmtstr(gOpts.tagfmt), tag)
					win.print(ui.screen, tagOff, i, tcell.StyleDefault, tagStr)
				}
			}

			if len(icon) > 0 {
				iconStyle := st
				if iconDef.hasStyle {
					iconStyle = iconDef.style
				}
				win.print(ui.screen, nameOff, i, iconStyle, icon)
			}

			win.print(ui.screen, nameOff+displaywidth.String(icon), i, st, filename+" ")

			// print over the empty space we reserved for the custom info
			if showInfo && custom != "" {
				win.print(ui.screen, customOff, i, st, custom)
			}
		}
	}
}

func getUserWidth(dir *dir, beg, end int) int {
	maxw := 0

	for _, f := range dir.files[beg:end] {
		maxw = max(len(userName(f.FileInfo)), maxw)
	}

	return maxw
}

func getGroupWidth(dir *dir, beg, end int) int {
	maxw := 0

	for _, f := range dir.files[beg:end] {
		maxw = max(len(groupName(f.FileInfo)), maxw)
	}

	return maxw
}

func getCustomWidth(dir *dir, beg, end int) int {
	maxw := 0

	for _, f := range dir.files[beg:end] {
		maxw = max(printLength(f.customInfo), maxw)
	}

	return maxw
}

func getWins(screen tcell.Screen) []*win {
	wtot, htot := screen.Size()

	h := max(htot-2, 0)
	x, y := 0, 1
	if gOpts.drawbox && gOpts.borderstyle&borderOutline != 0 {
		h = max(htot-4, 0)
		x, y = 1, 2
	}

	widths := getWidths(wtot, gOpts.ratios, gOpts.drawbox, gOpts.borderstyle)
	wins := make([]*win, 0, len(widths))
	for _, w := range widths {
		wins = append(wins, newWin(w, h, x, y))
		x += w + 1
	}

	return wins
}

type menuSelect struct {
	x, y int    // selection position in menuWin (cells)
	s    string // selected entry text to draw with `menuselectfmt`
}

type ui struct {
	screen      tcell.Screen       // primary screen used for drawing and event polling
	sxScreen    sixelScreen        // sixel preview state
	wins        []*win             // pane windows from `ratios` (last is `preview` when enabled)
	promptWin   *win               // prompt line window
	msgWin      *win               // status line window
	menuWin     *win               // menu window
	msg         string             // message/output shown in msgWin
	exprChan    chan expr          // expr queue
	evChan      chan tcell.Event   // merged event queue (keyChan + tevChan)
	menu        string             // rendered (multiline) menu text (i.e. completions, binds, marks)
	menuSelect  *menuSelect        // selected menu entry (completion menu only)
	cmdPrefix   string             // command prefix/prompt (empty: Normal mode)
	cmdAccLeft  string             // command buffer left of cursor
	cmdAccRight string             // command buffer right of cursor
	cmdYankBuf  string             // yank buffer for command line editing
	keyAcc      string             // keys typed so far for mapping lookup
	keyCount    string             // count prefix for next command
	styles      styleMap           // parsed styles
	icons       iconMap            // parsed icons
	ruler       *template.Template // compiled `rulerfile`
	rulerErr    error              // `rulerfile` parse error (if any)
	currentFile string             // last path passed to `on-select`
	pasteEvent  bool               // whether paste event is active (to ignore pasted input in Normal mode)
}

func newUI(screen tcell.Screen) *ui {
	wtot, htot := screen.Size()

	ui := &ui{
		screen:      screen,
		wins:        getWins(screen),
		promptWin:   newWin(wtot, 1, 0, 0),
		msgWin:      newWin(wtot, 1, 0, htot-1),
		menuWin:     newWin(wtot, 1, 0, htot-2),
		exprChan:    make(chan expr, 1000),
		evChan:      make(chan tcell.Event, 1000),
		styles:      parseStyles(),
		icons:       parseIcons(),
		currentFile: "",
		sxScreen:    sixelScreen{},
	}
	ui.ruler, ui.rulerErr = parseRuler(gOpts.rulerfile)

	return ui
}

func (ui *ui) winAt(x, y int) (int, *win) {
	for i, w := range slices.Backward(ui.wins) {
		if x >= w.x && y >= w.y && y < w.y+w.h {
			return i, w
		}
	}
	return -1, nil
}

func (ui *ui) renew() {
	ui.wins = getWins(ui.screen)

	wtot, htot := ui.screen.Size()
	ui.promptWin.renew(wtot, 1, 0, 0)
	ui.msgWin.renew(wtot, 1, 0, htot-1)
	ui.menuWin.renew(wtot, 1, 0, htot-2)
}

func (ui *ui) echo(msg string) {
	ui.msg = msg
}

func (ui *ui) echomsg(msg string) {
	ui.echo(sanitizeMessage(msg))
	log.Print(msg)
}

func (ui *ui) echoerr(msg string) {
	ui.echo(fmt.Sprintf(optionToFmtstr(gOpts.errorfmt), sanitizeName(msg)))
	log.Printf("error: %s", msg)
}

func (ui *ui) echoerrf(format string, a ...any) {
	ui.echoerr(fmt.Sprintf(format, a...))
}

// reg represents the preview for a file.
// This can also be used to represent the preview of a directory if
// `dirpreviews` is enabled.
//
// Note: the name `reg` is historical. It originally meant "regular"
// file preview, but `previewer` now also supports non-regular files.
type reg struct {
	loading  bool
	volatile bool
	loadTime time.Time
	path     string
	lines    []string
	sixel    bool
	height   int
}

func (ui *ui) loadFile(app *app, volatile bool) {
	if volatile {
		app.nav.previewChan <- ""
	}

	curr := app.nav.currFile()
	if curr == nil {
		return
	}

	if curr.path != ui.currentFile {
		ui.currentFile = curr.path
		onSelect(app)
	}

	if !gOpts.preview {
		return
	}

	if curr.isPreviewable() {
		app.nav.loadReg(curr.path, volatile)
	} else if curr.IsDir() {
		dir := app.nav.getDir(curr.path)
		app.nav.checkDir(dir)
	}
}

func (ui *ui) drawPromptLine(nav *nav) {
	st := tcell.StyleDefault

	dir := nav.currDir()
	pwd := sanitizeName(dir.path)

	if after, ok := strings.CutPrefix(pwd, gUser.HomeDir); ok {
		pwd = filepath.Join("~", after)
	}

	sep := string(filepath.Separator)

	var fname string
	if curr := nav.currFile(); curr != nil {
		fname = sanitizeName(filepath.Base(curr.path))
	}

	var prompt string

	prompt = strings.ReplaceAll(gOpts.promptfmt, "%u", gUser.Username)
	prompt = strings.ReplaceAll(prompt, "%h", gHostname)
	prompt = strings.ReplaceAll(prompt, "%f", fname)

	if printLength(strings.ReplaceAll(strings.ReplaceAll(prompt, "%w", pwd), "%d", pwd)) > ui.promptWin.w {
		names := strings.Split(pwd, sep)
		for i := range names {
			if names[i] == "" {
				continue
			}
			r, _ := utf8.DecodeRuneInString(names[i])
			names[i] = string(r)
			if printLength(strings.ReplaceAll(strings.ReplaceAll(prompt, "%w", strings.Join(names, sep)), "%d", strings.Join(names, sep))) <= ui.promptWin.w {
				break
			}
		}
		pwd = strings.Join(names, sep)
	}

	prompt = strings.ReplaceAll(prompt, "%w", pwd)
	if !strings.HasSuffix(pwd, sep) {
		pwd += sep
	}
	prompt = strings.ReplaceAll(prompt, "%d", pwd)

	if len(dir.filter) != 0 {
		prompt = strings.ReplaceAll(prompt, "%F", fmt.Sprint(dir.filter))
	} else {
		prompt = strings.ReplaceAll(prompt, "%F", "")
	}

	// spacer
	avail := ui.promptWin.w - printLength(prompt) + 2
	if avail > 0 {
		prompt = strings.Replace(prompt, "%S", strings.Repeat(" ", avail), 1)
	}
	prompt = strings.ReplaceAll(prompt, "%S", "")

	ui.promptWin.print(ui.screen, 0, 0, st, prompt)
}

// Deprecated: Only called by drawRuler, which will eventually be replaced by drawRulerFile
func formatRulerOpt(name, val string) string {
	// handle escape character so it doesn't mess up the ruler
	val = strings.ReplaceAll(val, "\033", "\033[7m\\033\033[0m")

	// display name of builtin options for clarity
	if !strings.HasPrefix(name, "lf_user_") {
		return strings.TrimPrefix(name, "lf_") + "=" + val
	}

	return val
}

// Deprecated: Will eventually be replaced by drawRulerFile
func (ui *ui) drawStat(nav *nav) {
	if ui.msg != "" {
		ui.msgWin.print(ui.screen, 0, 0, tcell.StyleDefault, ui.msg)
		return
	}

	curr := nav.currFile()
	if curr == nil {
		return
	}

	if curr.err != nil {
		ui.echoerrf("stat: %s", curr.err)
		ui.msgWin.print(ui.screen, 0, 0, tcell.StyleDefault, ui.msg)
		return
	}

	statfmt := strings.ReplaceAll(gOpts.statfmt, "|", "\x1f")
	replace := func(s, val string) {
		if val == "" {
			val = "\x00"
		}
		statfmt = strings.ReplaceAll(statfmt, s, val)
	}
	if nav.isVisualMode() {
		replace("%m", "VISUAL")
		replace("%M", "VISUAL")
	} else {
		replace("%m", "")
		replace("%M", "NORMAL")
	}
	replace("%p", permString(curr.Mode()))
	replace("%c", linkCount(curr))
	replace("%u", userName(curr))
	replace("%g", groupName(curr))
	replace("%s", humanize(curr.Size()))
	replace("%S", fmt.Sprintf("%5s", humanize(curr.Size())))
	replace("%t", curr.ModTime().Format(gOpts.timefmt))
	replace("%l", sanitizeName(curr.linkTarget))

	var fileInfo strings.Builder
	for section := range strings.SplitSeq(statfmt, "\x1f") {
		if !strings.Contains(section, "\x00") {
			fileInfo.WriteString(section)
		}
	}

	ui.msgWin.print(ui.screen, 0, 0, tcell.StyleDefault, fileInfo.String())
}

// Deprecated: Will eventually be replaced by drawRulerFile
func (ui *ui) drawRuler(nav *nav) {
	st := tcell.StyleDefault

	dir := nav.currDir()

	tot := len(dir.files)
	ind := min(dir.ind+1, tot)
	hid := len(dir.allFiles) - tot
	acc := ui.keyCount + ui.keyAcc

	var percentage string
	beg := max(dir.ind-dir.pos, 0)
	switch {
	case tot <= nav.height:
		percentage = "All"
	case beg == 0:
		percentage = "Top"
	case beg == tot-nav.height:
		percentage = "Bot"
	default:
		percentage = fmt.Sprintf("%2d%%", beg*100/(tot-nav.height))
	}

	numClipCopy := 0
	numClipMove := 0
	if nav.clipboard.mode == clipboardCopy {
		numClipCopy = len(nav.clipboard.paths)
	} else {
		numClipMove = len(nav.clipboard.paths)
	}

	numCurrSelections := len(nav.currSelections())
	numCurrVSelections := len(nav.currDir().visualSelections())

	var progress strings.Builder

	if nav.copyJobs > 0 {
		if nav.copyTotal == 0 {
			progress.WriteString("[0%] ")
		} else {
			progress.WriteString("[" + strconv.FormatInt(nav.copyBytes*100/nav.copyTotal, 10) + "%] ")
		}
	}

	if nav.moveTotal > 0 {
		progress.WriteString("[" + strconv.Itoa(nav.moveCount) + "/" + strconv.Itoa(nav.moveTotal) + "] ")
	}

	if nav.deleteTotal > 0 {
		progress.WriteString("[" + strconv.Itoa(nav.deleteCount) + "/" + strconv.Itoa(nav.deleteTotal) + "] ")
	}

	opts := getOptsMap()

	rulerfmt := strings.ReplaceAll(gOpts.rulerfmt, "|", "\x1f")
	rulerfmt = reRulerSub.ReplaceAllStringFunc(rulerfmt, func(s string) string {
		empty := "\x00"
		switch s {
		case "%a":
			return cmp.Or(acc, empty)
		case "%p":
			return cmp.Or(strings.TrimSuffix(progress.String(), " "), empty)
		case "%m":
			if numClipMove == 0 {
				return empty
			}
			return strconv.Itoa(numClipMove)
		case "%c":
			if numClipCopy == 0 {
				return empty
			}
			return strconv.Itoa(numClipCopy)
		case "%s":
			if numCurrSelections == 0 {
				return empty
			}
			return strconv.Itoa(numCurrSelections)
		case "%v":
			if numCurrVSelections == 0 {
				return empty
			}
			return strconv.Itoa(numCurrVSelections)
		case "%f":
			return cmp.Or(strings.Join(dir.filter, " "), empty)
		case "%i":
			return strconv.Itoa(ind)
		case "%t":
			return strconv.Itoa(tot)
		case "%h":
			return strconv.Itoa(hid)
		case "%P":
			return percentage
		case "%d":
			return cmp.Or(diskFree(dir.path), empty)
		default:
			s = strings.TrimSuffix(strings.TrimPrefix(s, "%{"), "}")
			if val, ok := opts[s]; ok {
				return cmp.Or(formatRulerOpt(s, val), empty)
			}
			return empty
		}
	})
	var ruler strings.Builder
	for section := range strings.SplitSeq(rulerfmt, "\x1f") {
		if !strings.Contains(section, "\x00") {
			ruler.WriteString(section)
		}
	}
	ui.msgWin.printRight(ui.screen, 0, st, ruler.String())
}

func (ui *ui) drawRulerFile(nav *nav) {
	if ui.rulerErr != nil {
		err := fmt.Sprintf(optionToFmtstr(gOpts.errorfmt), sanitizeName(fmt.Sprintf("parsing ruler: %s", ui.rulerErr)))
		ui.msgWin.print(ui.screen, 0, 0, tcell.StyleDefault, err)
		return
	}

	var stat *statData
	curr := nav.currFile()
	if curr != nil {
		if curr.err == nil {
			stat = &statData{
				Path:        sanitizeName(curr.path),
				Name:        sanitizeName(curr.Name()),
				Extension:   sanitizeName(curr.ext),
				Size:        curr.Size(),
				DirSize:     curr.dirSize,
				DirCount:    curr.dirCount,
				Permissions: permString(curr.Mode()),
				ModTime:     curr.ModTime().Format(gOpts.timefmt),
				AccessTime:  curr.accessTime.Format(gOpts.timefmt),
				BirthTime:   curr.birthTime.Format(gOpts.timefmt),
				ChangeTime:  curr.changeTime.Format(gOpts.timefmt),
				LinkCount:   linkCount(curr),
				User:        userName(curr),
				Group:       groupName(curr),
				Target:      sanitizeName(curr.linkTarget),
				CustomInfo:  curr.customInfo,
			}
		} else {
			ui.echoerrf("stat: %s", curr.err)
		}
	}

	dir := nav.currDir()
	tot := len(dir.files)
	ind := min(dir.ind+1, tot)
	all := len(dir.allFiles)
	hid := all - tot

	var linePercentage string
	if tot == 0 {
		linePercentage = "100%"
	} else {
		linePercentage = fmt.Sprintf("%d%%", ind*100/tot)
	}

	var scrollPercentage string
	beg := max(dir.ind-dir.pos, 0)
	switch {
	case tot <= nav.height:
		scrollPercentage = "All"
	case beg == 0:
		scrollPercentage = "Top"
	case beg == tot-nav.height:
		scrollPercentage = "Bot"
	default:
		scrollPercentage = fmt.Sprintf("%2d%%", beg*100/(tot-nav.height))
	}

	var copiedPaths []string
	var cutPaths []string
	if nav.clipboard.mode == clipboardCopy {
		copiedPaths = nav.clipboard.paths
	} else {
		cutPaths = nav.clipboard.paths
	}

	currSelections := nav.currSelections()
	currVSelections := nav.currDir().visualSelections()

	progress := []string{}

	if nav.copyJobs > 0 {
		if nav.copyTotal == 0 {
			progress = append(progress, fmt.Sprintf("[0%%]"))
		} else {
			progress = append(progress, fmt.Sprintf("[%d%%]", nav.copyBytes*100/nav.copyTotal))
		}
	}

	if nav.moveTotal > 0 {
		progress = append(progress, fmt.Sprintf("[%d/%d]", nav.moveCount, nav.moveTotal))
	}

	if nav.deleteTotal > 0 {
		progress = append(progress, fmt.Sprintf("[%d/%d]", nav.deleteCount, nav.deleteTotal))
	}

	mode := "NORMAL"
	if nav.isVisualMode() {
		mode = "VISUAL"
	}

	v := reflect.ValueOf(gOpts)
	t := v.Type()
	options := make(map[string]string, v.NumField())
	for i := range v.NumField() {
		name := t.Field(i).Name
		switch name {
		case "nkeys", "vkeys", "cmdkeys", "cmds", "user":
			continue
		default:
			options[name] = fieldToString(v.Field(i))
		}
	}

	data := rulerData{
		SPACER:           "\x1f",
		Message:          ui.msg,
		Keys:             ui.keyCount + ui.keyAcc,
		Progress:         progress,
		Copy:             copiedPaths,
		Cut:              cutPaths,
		Select:           currSelections,
		Visual:           currVSelections,
		Index:            ind,
		Total:            tot,
		Hidden:           hid,
		All:              all,
		LinePercentage:   linePercentage,
		ScrollPercentage: scrollPercentage,
		Filter:           dir.filter,
		Mode:             mode,
		Options:          options,
		UserOptions:      gOpts.user,
		Stat:             stat,
	}

	left, right, err := renderRuler(ui.ruler, data, ui.msgWin.w)
	if err != nil {
		err := fmt.Sprintf(optionToFmtstr(gOpts.errorfmt), sanitizeName(fmt.Sprintf("rendering ruler: %s", err)))
		ui.msgWin.print(ui.screen, 0, 0, tcell.StyleDefault, err)
		return
	}

	ui.msgWin.print(ui.screen, 0, 0, tcell.StyleDefault, left)
	ui.msgWin.printRight(ui.screen, 0, tcell.StyleDefault, right)
}

func (ui *ui) drawPreview(nav *nav, context *dirContext) {
	curr := nav.currFile()
	if curr == nil {
		return
	}

	win := ui.wins[len(ui.wins)-1]
	ui.sxScreen.clearSixel(win, ui.screen, curr.path)

	if gOpts.preview {
		if curr.isPreviewable() {
			reg, ok := nav.regCache[curr.path]
			if !ok {
				// the shown file can lose its cache entry, e.g. a save that deletes and recreates it
				reg = nav.loadReg(curr.path, false)
			}
			win.printReg(ui.screen, reg, &ui.sxScreen, nav.previewTimer)
		} else if curr.IsDir() {
			ui.sxScreen.lastFile = ""
			dir := nav.getDir(curr.path)
			dirStyle := &dirStyle{colors: ui.styles, icons: ui.icons, role: Preview}
			win.printDir(ui, dir, context, dirStyle, nav.previewTimer)
		}
	}
}

func (ui *ui) drawBox() {
	st := parseEscapeSequence(gOpts.borderfmt)

	w, h := ui.screen.Size()
	style := gOpts.borderstyle

	if style&borderOutline != 0 {
		for i := 1; i < w-1; i++ {
			ui.screen.PutStrStyled(i, 1, string(tcell.RuneHLine), st)
			ui.screen.PutStrStyled(i, h-2, string(tcell.RuneHLine), st)
		}

		for i := 2; i < h-2; i++ {
			ui.screen.PutStrStyled(0, i, string(tcell.RuneVLine), st)
			ui.screen.PutStrStyled(w-1, i, string(tcell.RuneVLine), st)
		}

		if style&borderRound != 0 {
			ui.screen.PutStrStyled(0, 1, "╭", st)
			ui.screen.PutStrStyled(w-1, 1, "╮", st)
			ui.screen.PutStrStyled(0, h-2, "╰", st)
			ui.screen.PutStrStyled(w-1, h-2, "╯", st)
		} else {
			ui.screen.PutStrStyled(0, 1, string(tcell.RuneULCorner), st)
			ui.screen.PutStrStyled(w-1, 1, string(tcell.RuneURCorner), st)
			ui.screen.PutStrStyled(0, h-2, string(tcell.RuneLLCorner), st)
			ui.screen.PutStrStyled(w-1, h-2, string(tcell.RuneLRCorner), st)
		}
	}

	if style&borderSeparators == 0 {
		return
	}

	top, bot := 1, h-1
	if style&borderOutline != 0 {
		top, bot = 2, h-2
	}

	for wind := range len(ui.wins) - 1 {
		x := ui.wins[wind].x + ui.wins[wind].w
		if style&borderOutline != 0 {
			ui.screen.PutStrStyled(x, 1, string(tcell.RuneTTee), st)
		}
		for y := top; y < bot; y++ {
			ui.screen.PutStrStyled(x, y, string(tcell.RuneVLine), st)
		}
		if style&borderOutline != 0 {
			ui.screen.PutStrStyled(x, h-2, string(tcell.RuneBTee), st)
		}
	}
}

func (ui *ui) drawMenu() {
	if ui.menu == "" {
		return
	}

	lines := strings.Split(ui.menu, "\n")
	lines = lines[:len(lines)-1]

	ui.menuWin.h = len(lines)
	ui.menuWin.y = ui.msgWin.y - ui.menuWin.h

	// clear sixel image if it overlaps with the menu
	ui.screen.LockRegion(ui.menuWin.x, ui.menuWin.y, ui.menuWin.w, ui.menuWin.h, false)
	ui.sxScreen.forceClear = true

	for i, line := range lines {
		var st tcell.Style
		if i == 0 {
			st = parseEscapeSequence(gOpts.menuheaderfmt)
		} else {
			st = parseEscapeSequence(gOpts.menufmt)
		}

		ui.menuWin.printLine(ui.screen, 0, i, st, line)
	}

	if ui.menuSelect != nil {
		st := parseEscapeSequence(gOpts.menuselectfmt)
		ui.menuWin.print(ui.screen, ui.menuSelect.x, ui.menuSelect.y, st, ui.menuSelect.s)
	}
}

func (ui *ui) dirOfWin(nav *nav, wind int) *dir {
	wins := len(ui.wins)
	if gOpts.preview {
		wins--
	}
	ind := len(nav.dirPaths) - wins + wind
	if ind < 0 {
		return nil
	}
	return nav.getDir(nav.dirPaths[ind])
}

func (ui *ui) draw(nav *nav) {
	st := tcell.StyleDefault
	context := dirContext{selections: nav.selections, clipboard: nav.clipboard, tags: nav.tags}

	ui.screen.Clear()

	ui.drawPromptLine(nav)

	wins := len(ui.wins)
	if gOpts.preview {
		wins--
	}
	for i := range wins {
		role := Parent
		if i == wins-1 {
			role = Active
		}
		if dir := ui.dirOfWin(nav, i); dir != nil {
			dirStyle := &dirStyle{colors: ui.styles, icons: ui.icons, role: role}
			ui.wins[i].printDir(ui, dir, &context, dirStyle, nav.previewTimer)
		}
	}

	// sanitize cmd-line buffers at render time
	cmdPrefix := sanitizeName(ui.cmdPrefix)
	cmdAccLeft := sanitizeName(ui.cmdAccLeft)
	cmdAccRight := sanitizeName(ui.cmdAccRight)

	switch ui.cmdPrefix {
	case "":
		if gOpts.rulerfmt == "" {
			ui.drawRulerFile(nav)
		} else {
			ui.drawStat(nav)
			ui.drawRuler(nav)
		}
		ui.screen.HideCursor()
	case ">":
		maxWidth := ui.msgWin.w - 1 // leave space for cursor at the end
		prefix := truncateRight(cmdPrefix, maxWidth)
		left := truncateLeft(cmdAccLeft, maxWidth-displaywidth.String(prefix)-printLength(ui.msg))
		ui.msgWin.printLine(ui.screen, 0, 0, st, prefix+ui.msg)
		ui.msgWin.print(ui.screen, displaywidth.String(prefix)+printLength(ui.msg), 0, st, left+cmdAccRight)
		ui.screen.ShowCursor(ui.msgWin.x+displaywidth.String(prefix)+printLength(ui.msg)+displaywidth.String(left), ui.msgWin.y)
	default:
		maxWidth := ui.msgWin.w - 1 // leave space for cursor at the end
		prefix := truncateRight(cmdPrefix, maxWidth)
		left := truncateLeft(cmdAccLeft, maxWidth-displaywidth.String(prefix))
		ui.msgWin.printLine(ui.screen, 0, 0, st, prefix+left+cmdAccRight)
		ui.screen.ShowCursor(ui.msgWin.x+displaywidth.String(prefix)+displaywidth.String(left), ui.msgWin.y)
	}

	ui.drawPreview(nav, &context)

	if gOpts.drawbox {
		ui.drawBox()
	}

	ui.drawMenu()

	ui.screen.Show()
}

func findBinds(keys map[string]expr, prefix string) (binds map[string]expr, exact bool) {
	binds = make(map[string]expr)
	for key, expr := range keys {
		if !strings.HasPrefix(key, prefix) {
			continue
		}
		binds[key] = expr
		if key == prefix {
			exact = true
		}
	}
	return binds, exact
}

func listBinds(binds map[string]map[string]expr) string {
	t := new(tabwriter.Writer)
	b := new(bytes.Buffer)

	// merge keys by command across modes
	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
		}
	}

	type entry struct {
		mode, key, cmd string
	}

	// collect normalized entries
	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})
		}
	}

	slices.SortFunc(entries, func(a, b entry) int {
		if c := cmp.Compare(a.key, b.key); c != 0 {
			return c
		}
		return cmp.Compare(a.mode, b.mode)
	})

	t.Init(b, 0, gOpts.tabstop, 2, '\t', 0)
	fmt.Fprintln(t, "mode\tkey\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 listMatchingBinds(binds map[string]expr, prefix string) string {
	t := new(tabwriter.Writer)
	b := new(bytes.Buffer)

	t.Init(b, 0, gOpts.tabstop, 2, '\t', 0)
	fmt.Fprintln(t, "key\tcommand")
	for _, k := range slices.Sorted(maps.Keys(binds)) {
		remain, _ := strings.CutPrefix(k, prefix)
		fmt.Fprintf(t, "%s\t%v\n", sanitizeName(remain), binds[k])
	}
	t.Flush()

	return b.String()
}

func listCmds(cmds map[string]expr) string {
	t := new(tabwriter.Writer)
	b := new(bytes.Buffer)

	t.Init(b, 0, gOpts.tabstop, 2, '\t', 0)
	fmt.Fprintln(t, "name\tcommand")
	for _, k := range slices.Sorted(maps.Keys(cmds)) {
		fmt.Fprintf(t, "%s\t%v\n", k, cmds[k])
	}
	t.Flush()

	return b.String()
}

func listJumps(jumps []string, ind int) string {
	t := new(tabwriter.Writer)
	b := new(bytes.Buffer)

	maxlength := len(strconv.Itoa(max(ind, len(jumps)-1-ind)))

	t.Init(b, 0, gOpts.tabstop, 2, '\t', 0)
	fmt.Fprintln(t, "  jump\tpath")
	// print jumps in order of most recent, Vim uses the opposite order
	for i, path := range slices.Backward(jumps) {
		switch {
		case i < ind:
			fmt.Fprintf(t, "  %*d\t%s\n", maxlength, ind-i, path)
		case i > ind:
			fmt.Fprintf(t, "  %*d\t%s\n", maxlength, i-ind, path)
		default:
			fmt.Fprintf(t, "> %*d\t%s\n", maxlength, 0, path)
		}
	}
	t.Flush()

	return b.String()
}

func listHistory(history []string) string {
	t := new(tabwriter.Writer)
	b := new(bytes.Buffer)

	maxlength := len(strconv.Itoa(len(history)))

	t.Init(b, 0, gOpts.tabstop, 2, '\t', 0)
	fmt.Fprintln(t, "number\tcommand")
	for i, cmd := range history {
		fmt.Fprintf(t, "%*d\t%s\n", maxlength, i+1, cmd)
	}
	t.Flush()

	return b.String()
}

func listMarks(marks map[string]string) string {
	t := new(tabwriter.Writer)
	b := new(bytes.Buffer)

	t.Init(b, 0, gOpts.tabstop, 2, '\t', 0)
	fmt.Fprintln(t, "mark\tpath")
	for _, k := range slices.Sorted(maps.Keys(marks)) {
		fmt.Fprintf(t, "%s\t%s\n", sanitizeName(k), sanitizeName(marks[k]))
	}
	t.Flush()

	return b.String()
}

func listFilesInCurrDir(nav *nav) string {
	dir := nav.currDir()
	if dir.loading {
		log.Printf("listFilesInCurrDir(): %s is still loading, `files` isn't ready for remote query", dir.path)
		return ""
	}

	b := new(strings.Builder)
	for _, file := range dir.files {
		if strings.ContainsAny(file.path, "\n\r") {
			continue
		}
		fmt.Fprintln(b, file.path)
	}

	return b.String()
}

// readNormalEvent is used to read a normal event on the client side. For keys,
// digits are interpreted as command counts but this is only done for digits
// preceding any non-digit characters (e.g. "42y2k" as 42 times "y2k").
func (ui *ui) readNormalEvent(ev tcell.Event, nav *nav) expr {
	draw := &callExpr{"draw", nil, 1}

	keys := gOpts.nkeys
	if nav.isVisualMode() {
		keys = gOpts.vkeys
	}

	switch tev := ev.(type) {
	case *tcell.EventKey:
		if ui.pasteEvent {
			return nil
		}

		isDigitKey := func(*tcell.EventKey) bool {
			if tev.Key() != tcell.KeyRune || tev.Modifiers() != tcell.ModNone {
				return false
			}

			s := tev.Str()
			return len(s) == 1 && s[0] >= '0' && s[0] <= '9'
		}

		switch {
		case tev.Key() == tcell.KeyEsc && ui.keyAcc != "":
			ui.keyAcc = ""
			ui.keyCount = ""
			ui.menu = ""
			return draw
		case isDigitKey(tev) && ui.keyAcc == "":
			ui.keyCount += tev.Str()
			return draw
		default:
			ui.keyAcc += readKey(tev)
		}

		binds, exact := findBinds(keys, ui.keyAcc)
		if len(binds) == 0 {
			ui.echoerrf("unknown mapping: %s", ui.keyAcc)
			ui.keyAcc = ""
			ui.keyCount = ""
			ui.menu = ""
			return draw
		}
		if !exact {
			if gOpts.showbinds {
				// mode and already typed keys are obvious here; no need to clutter the menu
				ui.menu = listMatchingBinds(binds, ui.keyAcc)
			}
			return draw
		}

		expr := keys[ui.keyAcc]
		if ui.keyCount != "" {
			if count, err := strconv.Atoi(ui.keyCount); err != nil {
				log.Printf("converting command count: %s", err)
			} else if count > 0 {
				switch e := expr.(type) {
				case *callExpr:
					expr = &callExpr{name: e.name, args: e.args, count: count}
				case *listExpr:
					expr = &listExpr{exprs: e.exprs, count: count}
				}
			}
		}

		ui.keyAcc = ""
		ui.keyCount = ""
		ui.menu = ""
		return expr

	case *tcell.EventMouse:
		if ui.cmdPrefix != "" {
			return nil
		}

		var button string

		switch tev.Buttons() {
		case tcell.Button1:
			button = "<m-1>"
		case tcell.Button2:
			button = "<m-2>"
		case tcell.Button3:
			button = "<m-3>"
		case tcell.Button4:
			button = "<m-4>"
		case tcell.Button5:
			button = "<m-5>"
		case tcell.Button6:
			button = "<m-6>"
		case tcell.Button7:
			button = "<m-7>"
		case tcell.Button8:
			button = "<m-8>"
		case tcell.WheelUp:
			button = "<m-up>"
		case tcell.WheelDown:
			button = "<m-down>"
		case tcell.WheelLeft:
			button = "<m-left>"
		case tcell.WheelRight:
			button = "<m-right>"
		case tcell.ButtonNone:
			return nil
		}
		if tev.Modifiers() == tcell.ModCtrl {
			button = "<c-" + button[1:]
		}
		if expr, ok := keys[button]; ok {
			return expr
		}
		if button != "<m-1>" && button != "<m-2>" {
			ui.echoerrf("unknown mapping: %s", button)
			ui.keyAcc = ""
			ui.keyCount = ""
			ui.menu = ""
			return draw
		}

		x, y := tev.Position()
		wind, w := ui.winAt(x, y)
		if wind == -1 {
			return nil
		}

		var dir *dir
		if gOpts.preview && wind == len(ui.wins)-1 {
			curr := nav.currFile()
			if curr == nil {
				return nil
			} else if !curr.IsDir() || gOpts.dirpreviews {
				if tev.Buttons() != tcell.Button2 {
					return nil
				}
				return &callExpr{"open", nil, 1}
			}

			dir = nav.getDir(curr.path)
		} else {
			dir = ui.dirOfWin(nav, wind)
			if dir == nil {
				return nil
			}
		}

		var file *file
		ind := dir.ind - dir.pos + y - w.y
		if ind < len(dir.files) {
			file = dir.files[ind]
		}

		if file != nil {
			sel := &callExpr{"select", []string{file.path}, 1}

			if tev.Buttons() == tcell.Button1 {
				return sel
			}
			if file.IsDir() {
				return &callExpr{"cd", []string{file.path}, 1}
			}
			return &listExpr{[]expr{sel, &callExpr{"open", nil, 1}}, 1}
		}
		if tev.Buttons() == tcell.Button1 {
			return &callExpr{"cd", []string{dir.path}, 1}
		}
	case *tcell.EventResize:
		return &callExpr{"redraw", nil, 1}
	case *tcell.EventError:
		log.Printf("Got EventError: '%s' at %s", tev.Error(), tev.When())
	case *tcell.EventInterrupt:
		log.Printf("Got EventInterrupt: at %s", tev.When())
	case *tcell.EventFocus:
		if tev.Focused {
			return &callExpr{"on-focus-gained", nil, 1}
		} else {
			return &callExpr{"on-focus-lost", nil, 1}
		}
	case *tcell.EventPaste:
		if tev.Start() {
			ui.pasteEvent = true
		} else if tev.End() {
			ui.pasteEvent = false
		}
	}
	return nil
}

func readCmdEvent(ev tcell.Event) expr {
	if tev, ok := ev.(*tcell.EventKey); ok {
		if tev.Key() == tcell.KeyRune && tev.Modifiers()&tcell.ModAlt == 0 {
			return &callExpr{"cmd-insert", []string{tev.Str()}, 1}
		}

		if expr, ok := gOpts.cmdkeys[readKey(tev)]; ok {
			return expr
		}
	}
	return nil
}

func (ui *ui) readEvent(ev tcell.Event, nav *nav) expr {
	if ev == nil {
		return nil
	}

	if _, ok := ev.(*tcell.EventKey); ok && ui.cmdPrefix != "" {
		return readCmdEvent(ev)
	}

	return ui.readNormalEvent(ev, nav)
}

func (ui *ui) readEvents() {
	for ev := range ui.screen.EventQ() {
		ui.evChan <- ev
	}
}

func (ui *ui) suspend() error {
	ui.sxScreen.forceClear = true
	return ui.screen.Suspend()
}

func (ui *ui) resume() error {
	return ui.screen.Resume()
}

func (ui *ui) exportSizes() {
	w, h := ui.screen.Size()
	os.Setenv("lf_width", strconv.Itoa(w))
	os.Setenv("lf_height", strconv.Itoa(h))
}

func anyKey() {
	fmt.Fprint(os.Stderr, gOpts.waitmsg)
	defer fmt.Fprintln(os.Stderr)
	oldState, err := term.MakeRaw(int(os.Stdin.Fd()))
	if err != nil {
		panic(err)
	}
	defer func() {
		if err := term.Restore(int(os.Stdin.Fd()), oldState); err != nil {
			panic(err)
		}
	}()

	b := make([]byte, 8)
	if _, err := os.Stdin.Read(b); err != nil {
		log.Printf("Failed to read key press: %s", err)
	}
}

func listMatches(screen tcell.Screen, matches []compMatch, selectedInd int) (string, *menuSelect) {
	if len(matches) < 2 {
		return "", nil
	}

	names := make([]string, len(matches))
	for i, m := range matches {
		names[i] = sanitizeName(m.name)
	}

	wtot, _ := screen.Size()
	wcol := 0
	for _, n := range names {
		wcol = max(wcol, printLength(n))
	}
	wcol += gOpts.tabstop - wcol%gOpts.tabstop
	ncol := max(wtot/wcol, 1)

	var b strings.Builder
	b.WriteString("possible matches")

	for i, n := range names {
		if i%ncol == 0 {
			b.WriteByte('\n')
		}
		w := printLength(n)
		fmt.Fprintf(&b, "%s%*s", n, wcol-w, "")
	}

	b.WriteByte('\n')

	var selection *menuSelect
	if selectedInd != -1 {
		selection = &menuSelect{selectedInd % ncol * wcol, selectedInd/ncol + 1, names[selectedInd]}
	}

	return b.String(), selection
}
