Vim-like search functionality (#112)

* feat: basic command system

* feat: implemented forward search, backward search and `:<slide>`-command

* fix: golint

* fix: removed `fmt.Sprintf`

* fix: proper args splitting, added `goto` example

* refactor: less verbose output, removed `goto`-example

* fix: ignore empty commands

* refactor: remove `:`-commands, moved `search` to `navigation` package

* feat: implemented different search-types: header-search, full-text search (case sensitive, insensitive and regex)

* feat: go to next occurrence with `ctrl+n`

* fix: regex search

* docs: added search instructions

* refactor: removed search types, style changes

* docs: update README.md

* docs: update README.md

* refactor: `GetSlides()` -> `Pages()`, `Buffer` -> `Query`

* feat: use `tea.KeyRunes` to allow international characters (such as `ä` or `動`)

* test: added test for search

* test: added ignore-case flag test
This commit is contained in:
Daniel
2021-10-27 18:29:20 +02:00
committed by GitHub
parent 4dd723db31
commit ac106d1060
5 changed files with 236 additions and 10 deletions

View File

@@ -142,15 +142,26 @@ Go to the previous slide with any of the following key sequences:
* number + any of the above (go back n slides)
Go to a specific slide with the following key sequence:
* number + <kbd>G</kbd>
Go to the last slide with the following key:
* <kbd>G</kbd>
### Search
To quickly jump to the right slide, you can use the search function.
Press <kbd>/</kbd>, enter your search term and press <kbd>Enter</kbd>
(*The search term is interpreted as a regular expression. The `/i` flag causes case-insensitivity.*).
Press <kbd>ctrl+n</kbd> after a search to go to the next search result.
### Code Execution
If slides finds a code block on the current slides it can execute the code
block and display the result as virtual text on the screen.
If slides finds a code block on the current slides it can execute the code block and display the result as virtual text
on the screen.
Press <kbd>ctrl+e</kbd> on a slide with a code block to execute it and display the result.

View File

@@ -38,6 +38,7 @@ type Model struct {
// VirtualText is used for additional information that is not part of the
// original slides, it will be displayed on a slide and reset on page change
VirtualText string
search navigation.Search
}
type fileWatchMsg struct{}
@@ -103,7 +104,42 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.KeyMsg:
keyPress := msg.String()
// add key presses to action buffer
if m.search.Active {
switch msg.Type {
case tea.KeyRunes:
k := string(msg.Runes)
// rune key: append to buffer
m.search.Write(k)
case tea.KeyEnter:
// execute current buffer
if m.search.Query != "" {
m.search.Execute(&m)
} else {
m.search.Done()
}
case tea.KeyBackspace:
// delete last char from buffer
m.search.Delete()
case tea.KeyCtrlC, tea.KeyEscape:
// quit command mode
m.search.Query = ""
m.search.Done()
}
return m, nil
}
switch keyPress {
case "/":
// Begin search
m.search.Begin()
return m, nil
case "ctrl+n":
// Go to next occurrence
m.search.Execute(&m)
case "ctrl+e":
// Run code blocks
blocks, err := code.Parse(m.Slides[m.Page])
@@ -122,8 +158,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.Quit
default:
newState := navigation.Navigate(navigation.State{
Buffer: m.buffer,
Page: m.Page,
Buffer: m.buffer,
Page: m.Page,
TotalSlides: len(m.Slides),
}, keyPress)
if newState.Page != m.Page {
@@ -156,7 +192,15 @@ func (m Model) View() string {
}
slide = styles.Slide.Render(slide)
left := styles.Author.Render(m.Author) + styles.Date.Render(m.Date)
var left string
if m.search.Active {
// render search bar
left = styles.ActionStatus.Render(fmt.Sprintf("/%s", m.search.Query))
} else {
// render author and date
left = styles.Author.Render(m.Author) + styles.Date.Render(m.Date)
}
right := styles.Page.Render(m.paging())
status := styles.Status.Render(styles.JoinHorizontal(left, right, m.viewport.Width))
return styles.JoinVertical(slide, status, m.viewport.Height)
@@ -227,3 +271,15 @@ func readStdin() (string, error) {
return b.String(), nil
}
func (m *Model) CurrentPage() int {
return m.Page
}
func (m *Model) SetPage(page int) {
m.Page = page
}
func (m *Model) Pages() []string {
return m.Slides
}

View File

@@ -0,0 +1,84 @@
package navigation
import (
"regexp"
"strings"
)
// Model is an interface for models.model, so that cycle imports are avoided
type Model interface {
CurrentPage() int
SetPage(page int)
Pages() []string
}
// Search represents the current search
type Search struct {
// Active - Show search bar instead of author and date?
// Store keystrokes in Query?
Active bool
// Query stores the current "search term"
Query string
}
// Mark Search as
// Done - Do not delete search buffer
// This is useful if, for example, you want to jump to the next result
// and you therefore still need the buffer
func (s *Search) Done() {
s.Active = false
}
// Begin a new search (deletes old buffer)
func (s *Search) Begin() {
s.Active = true
s.Query = ""
}
// Write a keystroke to the buffer
func (s *Search) Write(key string) {
s.Query += key
}
// Delete the last keystroke from the buffer
func (s *Search) Delete() {
if len(s.Query) > 0 {
s.Query = s.Query[0 : len(s.Query)-1]
}
}
// Execute search
func (s *Search) Execute(m Model) {
defer s.Done()
if s.Query == "" {
return
}
expr := s.Query
if strings.HasSuffix(expr, "/i") {
expr = "(?i)" + expr[:len(expr)-2]
}
pattern, err := regexp.Compile(expr)
if err != nil {
return
}
check := func(i int) bool {
content := m.Pages()[i]
if len(pattern.FindAllStringSubmatch(content, 1)) != 0 {
m.SetPage(i)
return true
}
return false
}
// search from next slide to end
for i := m.CurrentPage() + 1; i < len(m.Pages()); i++ {
if check(i) {
return
}
}
// search from first slide to previous
for i := 0; i < m.CurrentPage(); i++ {
if check(i) {
return
}
}
}

View File

@@ -0,0 +1,71 @@
package navigation
import (
"testing"
)
type mockModel struct {
slides []string
page int
}
func (m *mockModel) CurrentPage() int {
return m.page
}
func (m *mockModel) SetPage(page int) {
m.page = page
}
func (m *mockModel) Pages() []string {
return m.slides
}
func TestSearch(t *testing.T) {
data := []string{
"hi",
"first",
"second",
"third",
"AbCdEfG",
"abcdefg",
"seconds",
}
type query struct {
desc string
query string
expected int
}
// query -> expected page
queries := []query{
{"basic 'first'", "first", 1},
{"basic 'abc'", "abc", 5},
{"basic 'abc' next occurrence", "abc", 5},
{"'abc' ignore case", "abc/i", 4},
{"'abc' ignore case", "abc/i", 5},
{"'abc' ignore case", "abc/i", 4},
{"next occurrence 1/2", "sec", 6},
{"next occurrence 2/2", "sec", 2},
{"regex", "a.c", 5},
{"regex next occurrence", "a.c", 5},
{"regex ignore case", "a.c/i", 4},
{"regex ignore case next occurrence", "a.c/i", 5},
}
m := &mockModel{
slides: data,
page: 0,
}
s := &Search{}
for _, query := range queries {
s.Query = query.query
s.Execute(m)
if m.CurrentPage() != query.expected {
t.Errorf("[%s] expected page %d, got %d", query.desc, query.expected, m.CurrentPage())
}
}
}

View File

@@ -18,11 +18,15 @@ const (
)
var (
Author = lipgloss.NewStyle().Foreground(salmon).Align(lipgloss.Left).MarginLeft(2)
Date = lipgloss.NewStyle().Faint(true).Align(lipgloss.Left).Margin(0, 1)
Page = lipgloss.NewStyle().Foreground(salmon).Align(lipgloss.Right).MarginRight(3)
Slide = lipgloss.NewStyle().Padding(1)
Status = lipgloss.NewStyle().Padding(1)
Author = lipgloss.NewStyle().Foreground(salmon).Align(lipgloss.Left).MarginLeft(2)
Date = lipgloss.NewStyle().Faint(true).Align(lipgloss.Left).Margin(0, 1)
Page = lipgloss.NewStyle().Foreground(salmon).Align(lipgloss.Right).MarginRight(3)
Slide = lipgloss.NewStyle().Padding(1)
Status = lipgloss.NewStyle().Padding(1)
ActionStatus = lipgloss.NewStyle().
Foreground(salmon).
Align(lipgloss.Left).
MarginLeft(3)
)
var (