Commit e196f8d7 authored by Kevin Lyda's avatar Kevin Lyda
Browse files

Add search to pager

Closes #8.
parent 71acfef8
Loading
Loading
Loading
Loading
+107 −1
Original line number Diff line number Diff line
@@ -16,6 +16,60 @@ import (
	"golang.org/x/term"
)

// readLine reads a line of input in raw mode, echoing characters.
// Returns the entered string, or "" if ESC is pressed or input is empty.
func readLine(prompt string) string {
	fmt.Print(prompt)
	var buf []byte
	for {
		key, err := readKey()
		if err != nil {
			return ""
		}
		switch key {
		case 0x1b: // ESC
			return ""
		case '\r', '\n':
			return string(buf)
		case 0x7f, 0x08: // backspace
			if len(buf) > 0 {
				buf = buf[:len(buf)-1]
				fmt.Print("\b \b")
			}
		default:
			if key >= 0x20 {
				buf = append(buf, key)
				fmt.Printf("%c", key)
			}
		}
	}
}

// highlightLine returns the line with all case-insensitive occurrences of term
// wrapped in ANSI reverse video.
func highlightLine(line, searchTerm string) string {
	if searchTerm == "" {
		return line
	}
	lower := strings.ToLower(line)
	lowerTerm := strings.ToLower(searchTerm)
	var b strings.Builder
	i := 0
	for i < len(lower) {
		idx := strings.Index(lower[i:], lowerTerm)
		if idx < 0 {
			b.WriteString(line[i:])
			break
		}
		b.WriteString(line[i : i+idx])
		b.WriteString("\033[7m")
		b.WriteString(line[i+idx : i+idx+len(searchTerm)])
		b.WriteString("\033[0m")
		i += idx + len(searchTerm)
	}
	return b.String()
}

func getTerminalHeight() (int, error) {
	ws, err := unix.IoctlGetWinsize(0, unix.TIOCGWINSZ)
	if err != nil {
@@ -34,6 +88,30 @@ func readKey() (byte, error) {
	return buf[0], nil
}

// searchForward finds the next line containing term (case-insensitive),
// starting from the given line index. Returns -1 if not found.
func searchForward(lines []string, term string, from int) int {
	lower := strings.ToLower(term)
	for i := max(from, 0); i < len(lines); i++ {
		if strings.Contains(strings.ToLower(lines[i]), lower) {
			return i
		}
	}
	return -1
}

// searchBackward finds the previous line containing term (case-insensitive),
// searching backward from the given line index. Returns -1 if not found.
func searchBackward(lines []string, term string, from int) int {
	lower := strings.ToLower(term)
	for i := min(from, len(lines)-1); i >= 0; i-- {
		if strings.Contains(strings.ToLower(lines[i]), lower) {
			return i
		}
	}
	return -1
}

// SanitizeText removes terminal escape sequences and dangerous control
// characters from text, while preserving UTF-8 and normal whitespace.
func SanitizeText(s string) string {
@@ -76,6 +154,7 @@ func Pager(content string) bool {
	defer term.Restore(fd, oldState) // #nosec G115

	start := 0
	searchTerm := ""
	for {
		// State for where we are.
		if start < 0 {
@@ -85,7 +164,11 @@ func Pager(content string) bool {

		// Clear prompt and display the chunk.
		fmt.Print("\r          \r")
		fmt.Print(strings.Join(lines[start:end], "\r\n"))
		visible := make([]string, end-start)
		for i, line := range lines[start:end] {
			visible[i] = highlightLine(line, searchTerm)
		}
		fmt.Print(strings.Join(visible, "\r\n"))

		if end == totalLines {
			fmt.Println("\r") // move to next line after paging ends
@@ -110,6 +193,29 @@ func Pager(content string) bool {
		case 'q', 'Q': // quit
			fmt.Println("\r") // move cursor to next line
			return false
		case '/': // search
			fmt.Print("\r                    \r")
			t := readLine("/")
			if t != "" {
				searchTerm = t
				if pos := searchForward(lines, searchTerm, start); pos >= 0 {
					start = pos
				} else {
					fmt.Print("\r\nNot found")
				}
			}
		case 'n': // next match
			if searchTerm != "" {
				if pos := searchForward(lines, searchTerm, start+1); pos >= 0 {
					start = pos
				}
			}
		case 'N': // previous match
			if searchTerm != "" {
				if pos := searchBackward(lines, searchTerm, start-1); pos >= 0 {
					start = pos
				}
			}
		}
	}
}