mirror of
https://github.com/maaslalani/slides.git
synced 2026-01-08 22:07:59 -05:00
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:
15
README.md
15
README.md
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
84
internal/navigation/search.go
Normal file
84
internal/navigation/search.go
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
71
internal/navigation/search_test.go
Normal file
71
internal/navigation/search_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user