diff --git a/docs/allocations.md b/docs/allocations.md index 4a211c4..596520c 100644 --- a/docs/allocations.md +++ b/docs/allocations.md @@ -119,6 +119,9 @@ A line under the title shows the state of three toggles: in the current directory, as the filter shows it. `c` copies it to the clipboard. +`f` asks for a word and lights it in the log without hiding any line. `n` and +`N` go to the next and the previous match. + ## Logs of every allocation `l` on a job, a task group or a list of allocations opens the log of one task @@ -139,7 +142,7 @@ shows the lines of one allocation. allocations again and opens the logs of the ones that run now, for example after a deployment. - `ctrl-e` switches every log between stdout and stderr. -- Autoscroll, timestamps, wrap and saving work as on the log of one task. +- Autoscroll, timestamps, wrap, find and saving work as on the log of one task. - `esc` closes every log and goes back. ## Files @@ -161,7 +164,8 @@ The list shows the name, size and age of each file. Directories come first. - It opens at the top, with Autoscroll off. `s` turns Autoscroll on to follow the end as the file grows. - A file larger than 1 MiB opens at its last 1 MiB. The title says so. -- `w` wraps long lines, `/` filters them, `ctrl-s` saves the file, `c` copies it. +- `w` wraps long lines, `/` filters them, `f` finds a word without hiding + lines, `ctrl-s` saves the file, `c` copies it. urga does not open: diff --git a/docs/keys.md b/docs/keys.md index a116a25..06cffe2 100644 --- a/docs/keys.md +++ b/docs/keys.md @@ -265,6 +265,8 @@ On what was done to the count of a group, `d` describes its policy. | `w` | Wrap long lines | | | `ctrl-s` | Save the plan to a file | | | `c` | Copy the plan to the clipboard | | +| `f` | Find a word: every line stays and the word is lit | | +| `n`, `N` | Next or previous match. Shown once a word is found. | | ### Allocations @@ -308,6 +310,8 @@ evaluation. | `p` | Same log in the allocation this one replaced | | `ctrl-s` | Save the log to a file | | `c` | Copy the log to the clipboard, the last 64 KiB at most | +| `f` | Find a word: every line stays and the word is lit | +| `n`, `N` | Next or previous match. Shown once a word is found. | ### Logs of every allocation @@ -320,6 +324,8 @@ evaluation. | `w` | Wrap on or off | | `ctrl-s` | Save the logs to a file | | `c` | Copy the logs to the clipboard | +| `f` | Find a word: every line stays and the word is lit | +| `n`, `N` | Next or previous match. Shown once a word is found. | In the list of tasks that opens first when there are several, `enter` opens the logs of the task under the cursor. @@ -338,6 +344,8 @@ On an open file: | `w` | Wrap on or off | | `ctrl-s` | Save the file to a local file | | `c` | Copy the file to the clipboard | +| `f` | Find a word: every line stays and the word is lit | +| `n`, `N` | Next or previous match. Shown once a word is found. | ### Overview @@ -521,7 +529,18 @@ Descriptions, job files and diffs: | `w` | Wrap on or off | | `ctrl-s` | Save to a file | | `c` | Copy to the clipboard | +| `f` | Find a word: every line stays and the word is lit | +| `n`, `N` | Next or previous match. Shown once a word is found. | On every text screen, logs, files and plans included, `c` copies up to 64 KiB: the last whole lines when the text is longer, so the start of a long log is left out. `ctrl-s` saves all of it. + +On every text screen, `f` asks for a word and keeps every line on the screen, +lighting the word where it is. The window goes to the first match from its +top, and the status line says which one it is. `n` and `N` go to the next and +the previous match, round past the end and the start; after you scroll away +from a match, they start from what the window shows. An empty word drops the find, and +`esc` keeps the word as it was. The filter (`/`) still narrows the lines, and +find looks within what it left. Like the filter, the word is a regular +expression when it is one, and matches in any case. diff --git a/internal/ui/joblogs.go b/internal/ui/joblogs.go index 43b0db5..550460c 100644 --- a/internal/ui/joblogs.go +++ b/internal/ui/joblogs.go @@ -501,7 +501,7 @@ func (p jobLogsPage) lines(allocID string, style *lipgloss.Style, lines ...strin } // jobLogsKeys are the keys of the logs of a job. -var jobLogsKeys = []pageKey[jobLogsPage]{ +var jobLogsKeys = append([]pageKey[jobLogsPage]{ {press: "r", label: "Reload", do: reloadJobLogs}, // The key that opens stderr from the tasks switches between stdout and // stderr here, and its label names the one it switches to. @@ -512,7 +512,7 @@ var jobLogsKeys = []pageKey[jobLogsPage]{ timesKey[jobLogsPage](), saveKey[jobLogsPage](), copyTextKey[jobLogsPage](), -} +}, findWordKeys[jobLogsPage]()...) func (p jobLogsPage) keys(e env) []keyHint { return hintsOf(p, e, jobLogsKeys) } diff --git a/internal/ui/joblogs_test.go b/internal/ui/joblogs_test.go index eba0a75..c588f6f 100644 --- a/internal/ui/joblogs_test.go +++ b/internal/ui/joblogs_test.go @@ -511,6 +511,7 @@ func TestJobLogs_TheHeaderSaysWhatTheLogsCanDo(t *testing.T) { {Key: "", Description: "Toggle Timestamps"}, {Key: "", Description: "Save"}, {Key: "", Description: "Copy"}, + {Key: "", Description: "Find"}, }, m.hints()) } diff --git a/internal/ui/logs.go b/internal/ui/logs.go index 749a0d5..f088374 100644 --- a/internal/ui/logs.go +++ b/internal/ui/logs.go @@ -136,7 +136,7 @@ func (p logsPage) take(msg tea.Msg, _ env) (page, outcome, bool) { // logsKeys are the keys of a log: the ones of any text, and the ones of a // stream that is still being followed. -var logsKeys = []pageKey[logsPage]{ +var logsKeys = append([]pageKey[logsPage]{ followKey[logsPage](), // The key that opens stderr from the tasks switches to the other of // the two here, and its label names the one it goes to. @@ -147,7 +147,7 @@ var logsKeys = []pageKey[logsPage]{ timesKey[logsPage](), saveKey[logsPage](), copyTextKey[logsPage](), -} +}, findWordKeys[logsPage]()...) func (p logsPage) keys(e env) []keyHint { return hintsOf(p, e, logsKeys) } diff --git a/internal/ui/logs_test.go b/internal/ui/logs_test.go index 7a955f9..d6aa5c9 100644 --- a/internal/ui/logs_test.go +++ b/internal/ui/logs_test.go @@ -636,6 +636,7 @@ func TestLogs_TheHeaderSaysWhatALogCanDo(t *testing.T) { {Key: "", Description: "Toggle Timestamps"}, {Key: "", Description: "Save"}, {Key: "", Description: "Copy"}, + {Key: "", Description: "Find"}, }, m.hints()) // The allocation before is offered where there is one. @@ -649,6 +650,7 @@ func TestLogs_TheHeaderSaysWhatALogCanDo(t *testing.T) { {Key: "", Description: "Toggle Timestamps"}, {Key: "", Description: "Save"}, {Key: "", Description: "Copy"}, + {Key: "", Description: "Find"}, }, m.hints()) } diff --git a/internal/ui/overlay.go b/internal/ui/overlay.go index 72f9c00..7ecac9f 100644 --- a/internal/ui/overlay.go +++ b/internal/ui/overlay.go @@ -421,22 +421,37 @@ func matchIn(line, filter string) []int { return nil } - rx, err := regexp.Compile("(?i)" + filter) + return wordIn(line, filter) +} + +// wordIn is where a word matches in a line, as it is written: a regular +// expression, or the plain text when it is not one. +func wordIn(line, word string) []int { return wordFinder(word)(line) } + +// wordFinder is wordIn with the word read once, for many lines. +func wordFinder(word string) func(line string) []int { + rx, err := regexp.Compile("(?i)" + word) if err != nil { - at := strings.Index(strings.ToLower(line), strings.ToLower(filter)) - if at < 0 || filter == "" { - return nil - } + lower := strings.ToLower(word) - return []int{at, at + len(filter)} - } + return func(line string) []int { + at := strings.Index(strings.ToLower(line), lower) + if at < 0 || word == "" { + return nil + } - at := rx.FindStringIndex(line) - if at == nil || at[0] == at[1] { - return nil + return []int{at, at + len(word)} + } } - return at + return func(line string) []int { + at := rx.FindStringIndex(line) + if at == nil || at[0] == at[1] { + return nil + } + + return at + } } // The prefixes a filter can start with, which change how it matches. diff --git a/internal/ui/page.go b/internal/ui/page.go index bd98b1e..3c3b3d2 100644 --- a/internal/ui/page.go +++ b/internal/ui/page.go @@ -78,6 +78,9 @@ type env struct { // readOnly takes away every key that changes the cluster. readOnly bool + // found says a word is found in the text on the screen. + found bool + // build is this urga, for the about screen. build buildInfo } @@ -102,6 +105,7 @@ func (m Model) env() env { clusters: m.opts.Clusters, namespaces: m.namespaces, readOnly: m.opts.ReadOnly, + found: m.text.found != "", build: buildInfo{ version: m.opts.Version, commit: m.opts.Commit, @@ -249,6 +253,11 @@ type ( followMsg struct{} timesMsg struct{} + // findWordMsg asks for a word to find in the text, and matchMsg goes to the + // next match of it, or the previous one when negative. + findWordMsg struct{} + matchMsg int + // reopenMsg opens the stream of the page again, the way entering the // page does, in the same window: the page now reads another stream. reopenMsg struct{} @@ -370,7 +379,18 @@ type reader interface { // textKeys are the keys of a page that reads as text. func textKeys[P page]() []pageKey[P] { - return []pageKey[P]{wrapKey[P](), saveKey[P](), copyTextKey[P]()} + return append([]pageKey[P]{wrapKey[P](), saveKey[P](), copyTextKey[P]()}, findWordKeys[P]()...) +} + +// findWordKeys find a word in a text, and go between the matches of it. +func findWordKeys[P page]() []pageKey[P] { + found := func(_ P, e env) bool { return e.found } + + return []pageKey[P]{ + windowKey[P]("f", "Find", findWordMsg{}), + {press: "n", label: "Next Match", offered: found, do: func(p P, _ env) (P, outcome) { return p, then(matchMsg(1)) }}, + {press: "N", label: "Previous Match", offered: found, do: func(p P, _ env) (P, outcome) { return p, then(matchMsg(-1)) }}, + } } func wrapKey[P page]() pageKey[P] { return windowKey[P]("w", "Toggle Wrap", wrapMsg{}) } diff --git a/internal/ui/plan_test.go b/internal/ui/plan_test.go index ab50bf3..c0de841 100644 --- a/internal/ui/plan_test.go +++ b/internal/ui/plan_test.go @@ -434,6 +434,7 @@ func TestPlan_TheHeaderSaysWhatAPlanCanDo(t *testing.T) { {Key: "", Description: "Toggle Wrap"}, {Key: "", Description: "Save"}, {Key: "", Description: "Copy"}, + {Key: "", Description: "Find"}, }, m.hints()) // With nothing to send, there is no key that sends. @@ -445,6 +446,7 @@ func TestPlan_TheHeaderSaysWhatAPlanCanDo(t *testing.T) { {Key: "", Description: "Toggle Wrap"}, {Key: "", Description: "Save"}, {Key: "", Description: "Copy"}, + {Key: "", Description: "Find"}, }, m.hints()) } diff --git a/internal/ui/text.go b/internal/ui/text.go index 80e67d3..abe77f4 100644 --- a/internal/ui/text.go +++ b/internal/ui/text.go @@ -1,6 +1,7 @@ package ui import ( + "sort" "strings" "time" @@ -17,6 +18,13 @@ type textModel struct { // filter keeps only the lines that match it. filter string + // found is the word lit in the lines, which n and N go between the + // matches of. It hides no line. + found string + + // at is the row of the match the window went to last. + at int + // wrap lays a long line over as many rows as it takes, instead of // cutting it at the edge of the screen. wrap bool @@ -391,6 +399,7 @@ func (t *textModel) refresh() { k.rows = k.rows[gone:] k.counts = k.counts[t.dropped-first:] t.top = max(t.top-gone, 0) + t.at = max(t.at-gone, 0) rows, counts := t.draw(k.lines-t.dropped, len(t.lines)) k.rows = append(k.rows, rows...) @@ -464,7 +473,7 @@ func (t textModel) view() string { return strings.Join(rows, "\n") } -// line draws one row in its colour, with the filter matches highlighted. +// line draws one row in its colour, with the matches highlighted. func (t textModel) line(row textRow) string { line := truncate(row.text, t.width) gap := strings.Repeat(" ", max(t.width-ansi.StringWidth(line), 0)) @@ -487,20 +496,26 @@ func (t textModel) line(row textRow) string { t.lit(ansi.TruncateLeft(line, row.tagTo, ""), style) + style.Render(gap) } -// lit is a piece of a row in its style, with the filter matches highlighted. +// lit is a piece of a row in its style, with the matches of the found word +// highlighted, or of the filter when no word is found. func (t textModel) lit(piece string, style lipgloss.Style) string { if piece == "" { return "" } - if t.filter == "" { + word, find := t.filter, matchIn + if t.found != "" { + word, find = t.found, wordIn + } + + if word == "" { return style.Render(piece) } out := strings.Builder{} for rest := piece; rest != ""; { - at := matchIn(rest, t.filter) + at := find(rest, word) if at == nil { out.WriteString(style.Render(rest)) @@ -515,3 +530,46 @@ func (t textModel) lit(piece string, style lipgloss.Style) string { return out.String() } + +// matchRows are the rows the found word is in. +func (t textModel) matchRows() []int { + var hits []int + + find := wordFinder(t.found) + + for i, row := range t.rows() { + if find(row.text) != nil { + hits = append(hits, i) + } + } + + return hits +} + +// seek moves the window to the first match from row from on, or the last one +// before it when backward, going round past the end. It says which match that +// is out of how many; of is 0 when no row matches, and the window stays. +func (t *textModel) seek(from int, backward bool) (nth, of int) { + hits := t.matchRows() + if len(hits) == 0 { + return 0, 0 + } + + i := sort.SearchInts(hits, from) + if backward { + i = sort.SearchInts(hits, from+1) - 1 + } + + // Past either end, it goes round. + if i < 0 { + i = len(hits) - 1 + } else if i >= len(hits) { + i = 0 + } + + t.at = hits[i] + t.move(max(t.at-t.height/3, 0) - t.top) + t.following = false + + return i + 1, len(hits) +} diff --git a/internal/ui/textfind.go b/internal/ui/textfind.go new file mode 100644 index 0000000..8f71036 --- /dev/null +++ b/internal/ui/textfind.go @@ -0,0 +1,54 @@ +package ui + +import ( + tea "charm.land/bubbletea/v2" +) + +// askFind opens the line that asks for the word to find. +func (m Model) askFind() (Model, tea.Cmd) { + return m.askLine(lineMsg{prefix: "find: ", answer: findWord}) +} + +// findWord lights the word and goes to its first match from the top of the +// window. An empty word drops the find; a word that matches nothing leaves +// the one found before. +func findWord(m Model, word string) (Model, tea.Cmd) { + if word == "" { + m.text.found = "" + + return m, nil + } + + before := m.text.found + m.text.found = word + + nth, of := m.text.seek(m.text.top, false) + if of == 0 { + m.text.found = before + + return m.warn(sprintf(`No line matches "%s".`, word)), nil + } + + return m.say(sprintf(`Match %d of %d for "%s".`, nth, of, word)), nil +} + +// goToMatch moves the window to the next match of the found word, or the +// previous one when by is negative. A window scrolled away from the match it +// was on starts from what it shows: its top going down, its bottom going up. +func (m Model) goToMatch(by int) (Model, tea.Cmd) { + from := m.text.at + by + + if m.text.at < m.text.top || m.text.at >= m.text.top+m.text.height { + from = m.text.top + if by < 0 { + from = m.text.top + m.text.height - 1 + } + } + + nth, of := m.text.seek(from, by < 0) + if of == 0 { + return m.warn(sprintf(`No line matches "%s".`, m.text.found)), nil + } + + return m.say(sprintf(`Match %d of %d for "%s".`, nth, of, m.text.found)), nil +} diff --git a/internal/ui/textfind_test.go b/internal/ui/textfind_test.go new file mode 100644 index 0000000..1407e33 --- /dev/null +++ b/internal/ui/textfind_test.go @@ -0,0 +1,326 @@ +package ui + +import ( + "fmt" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/stretchr/testify/require" +) + +// errorLog is a log of 100 lines, with "error" on lines 20, 50 and 80, in a +// window of 10 rows. +func errorLog(t *testing.T) Model { + t.Helper() + + lines := make([]string, 0, 100) + for i := range 100 { + line := fmt.Sprintf("line %03d", i) + if i == 20 || i == 50 || i == 80 { + line += " error" + } + + lines = append(lines, line+"\n") + } + + m, _ := onLogs(t, lines...) + + return windowOf(m, 10) +} + +// windowOf resizes the screen so that the window over the text has that many +// rows. +func windowOf(m Model, rows int) Model { + m, _ = m.update(tea.WindowSizeMsg{Width: 120, Height: 30 - (m.text.height - rows)}) + + return m +} + +func foundIn(m Model, word string) Model { + m, _ = m.update(key('f')) + m = typeIn(m, word) + m, _ = m.update(enter()) + + return m +} + +func status(m Model) string { return plain(statusLine(m)) } + +func TestFind_KeepsEveryLineAndLightsTheWord(t *testing.T) { + r := require.New(t) + + m := errorLog(t) + r.Equal(10, m.text.height) + + m, _ = m.update(key('f')) + r.Equal(overlayAnswer, m.overlay) + r.Contains(plain(m.render()), "find: ") + + m = typeIn(m, "error") + m, _ = m.update(enter()) + + r.Equal(100, m.text.length()) + + // The window had no match at or below its top row, so it goes round to + // the first one, a third of the way down. + out := plain(m.render()) + r.Contains(out, "line 020 error") + r.Contains(out, "line 017") + r.NotContains(out, "line 016") + r.Contains(status(m), `Match 1 of 3 for "error".`) + r.Contains(m.render(), styleMatch.Render("error")) +} + +func TestFind_StartsFromTheTopOfTheWindow(t *testing.T) { + r := require.New(t) + + m := errorLog(t) + m.text.top = 40 + + m = foundIn(m, "error") + + r.Contains(plain(m.render()), "line 050 error") + r.Contains(status(m), `Match 2 of 3 for "error".`) +} + +func TestFind_NextAndPreviousGoRound(t *testing.T) { + r := require.New(t) + + m := foundIn(errorLog(t), "error") + r.Contains(plain(m.render()), "line 020 error") + + for _, want := range []struct{ line, says string }{ + {"line 050 error", "Match 2 of 3"}, + {"line 080 error", "Match 3 of 3"}, + {"line 020 error", "Match 1 of 3"}, + } { + m, _ = m.update(key('n')) + + r.Contains(plain(m.render()), want.line) + r.Contains(status(m), want.says+` for "error".`) + } + + m, _ = m.update(key('N')) + + r.Contains(plain(m.render()), "line 080 error") + r.Contains(status(m), `Match 3 of 3 for "error".`) +} + +func TestFind_NextAndPreviousStartFromAWindowScrolledAway(t *testing.T) { + r := require.New(t) + + // The match found is at line 20; the window goes to the top, where line + // 20 is out of sight, and n starts from what is on the screen. + m := foundIn(errorLog(t), "error") + m, _ = m.update(key('g')) + m, _ = m.update(key('n')) + + r.Contains(plain(m.render()), "line 020 error") + r.Contains(status(m), `Match 1 of 3 for "error".`) + + // From the end, N goes to the last match above the bottom of the window. + m, _ = m.update(key('G')) + m, _ = m.update(key('N')) + + r.Contains(plain(m.render()), "line 080 error") + r.Contains(status(m), `Match 3 of 3 for "error".`) +} + +func TestFind_NextAndPreviousAreOfferedWhileAWordIsFound(t *testing.T) { + r := require.New(t) + + m := errorLog(t) + r.True(offers(m, "f")) + r.False(offers(m, "n")) + r.False(offers(m, "N")) + + m = foundIn(m, "error") + r.True(offers(m, "n")) + r.True(offers(m, "N")) + + // An empty word drops the find. + m, _ = m.update(key('f')) + m, _ = m.update(enter()) + + r.False(offers(m, "n")) + r.False(offers(m, "N")) + r.NotContains(m.render(), styleMatch.Render("error")) +} + +func TestFind_AWordThatMatchesNothingLeavesTheWindow(t *testing.T) { + r := require.New(t) + + m := errorLog(t) + top := m.text.top + + m = foundIn(m, "nothing like it") + + r.Equal(top, m.text.top) + r.Equal(flashWarn, m.flash.level) + r.Contains(status(m), `No line matches "nothing like it".`) + r.False(offers(m, "n")) +} + +func TestFind_StopsFollowingAStream(t *testing.T) { + r := require.New(t) + + m := errorLog(t) + r.True(m.text.following) + + m = foundIn(m, "error") + r.False(m.text.following) + + m.text.following = true + m, _ = m.update(key('n')) + + r.False(m.text.following) +} + +func TestFind_CountsTheRowsOfAWrappedLine(t *testing.T) { + r := require.New(t) + + lines := make([]string, 0, 61) + for range 30 { + lines = append(lines, "short\n") + } + + lines = append(lines, strings.Repeat("a", 200)+" target\n") + + for range 30 { + lines = append(lines, "short\n") + } + + m, _ := onLogs(t, lines...) + m = windowOf(m, 10) + m, _ = m.update(key('w')) + m = foundIn(m, "target") + + // The second row of the long line is row 31. + r.Equal(31-10/3, m.text.top) + r.Contains(plain(m.render()), "target") + r.Contains(status(m), `Match 1 of 1 for "target".`) +} + +func TestFind_LooksWithinWhatTheFilterLeft(t *testing.T) { + r := require.New(t) + + m := errorLog(t) + m, _ = m.update(key('/')) + m = typeIn(m, "line 0[0-5]") + m, _ = m.update(enter()) + + m = foundIn(m, "error") + + r.Contains(status(m), `Match 1 of 2 for "error".`) +} + +func TestFind_EscapeKeepsTheWordFoundBefore(t *testing.T) { + r := require.New(t) + + m := foundIn(errorLog(t), "error") + + m, _ = m.update(key('f')) + m = typeIn(m, "other") + m, _ = m.update(escape()) + + r.Equal("error", m.text.found) + r.True(offers(m, "n")) + r.Contains(m.render(), styleMatch.Render("error")) +} + +func TestFind_TakesAWordAsWrittenWhereAFilterPrefixMeansSomething(t *testing.T) { + for _, word := range []string{"!boom", "-f config"} { + t.Run(word, func(t *testing.T) { + r := require.New(t) + + m, _ := onLogs(t, "quiet\n", "a line with "+word+" in it\n", "quiet\n") + m = foundIn(m, word) + + r.Contains(status(m), `Match 1 of 1 for "`+word+`".`) + r.Contains(m.render(), styleMatch.Render(word)) + }) + } +} + +func TestFind_PreviousStepsBackWithoutGoingRound(t *testing.T) { + r := require.New(t) + + m := foundIn(errorLog(t), "error") + + m, _ = m.update(key('n')) + m, _ = m.update(key('n')) + r.Contains(status(m), `Match 3 of 3 for "error".`) + + m, _ = m.update(key('N')) + + r.Contains(plain(m.render()), "line 050 error") + r.Contains(status(m), `Match 2 of 3 for "error".`) +} + +func TestFind_AWordThatMatchesNothingKeepsTheWordFoundBefore(t *testing.T) { + r := require.New(t) + + m := foundIn(errorLog(t), "error") + m = foundIn(m, "nothing like it") + + r.Equal(flashWarn, m.flash.level) + r.True(offers(m, "n")) + + m, _ = m.update(key('n')) + + r.Contains(plain(m.render()), "line 050 error") + r.Contains(status(m), `Match 2 of 3 for "error".`) +} + +func TestFind_NextWarnsWhenTheFilterLeftNoMatch(t *testing.T) { + r := require.New(t) + + m := foundIn(errorLog(t), "error") + m, _ = m.update(key('/')) + m = typeIn(m, "line 01") + m, _ = m.update(enter()) + + top, at := m.text.top, m.text.at + + m, _ = m.update(key('n')) + + r.Equal(flashWarn, m.flash.level) + r.Contains(status(m), `No line matches "error".`) + r.Equal(top, m.text.top) + r.Equal(at, m.text.at) +} + +func TestFind_KeepsItsPlaceWhenLinesLeaveTheFront(t *testing.T) { + r := require.New(t) + + lines := func(first, count int) []string { + out := linesFrom(first, count) + for i := range out { + if (first+i)%1000 == 0 { + out[i] += " error" + } + } + + return out + } + + text := textModel{found: "error"} + text.setSize(40, 6) + text.add(lines(0, maxTextLines), arrivedAt, nil, nil) + text.refresh() + + // The match on line 5000, the sixth. + nth, _ := text.seek(4001, false) + r.Equal(6, nth) + r.Equal(5000, text.at) + + text.add(lines(maxTextLines, 3000), arrivedAt, nil, nil) + text.refresh() + r.Equal(3000, text.dropped) + + // Lines 0, 1000 and 2000 are gone, so the match on line 5000 is the third + // and the next one is the fourth. + nth, _ = text.seek(text.at+1, false) + r.Equal(4, nth) +} diff --git a/internal/ui/update.go b/internal/ui/update.go index 66c8ca3..af8a6a4 100644 --- a/internal/ui/update.go +++ b/internal/ui/update.go @@ -81,6 +81,12 @@ func (m Model) fromPage(msg tea.Msg) (Model, tea.Cmd, bool) { case timesMsg: return taken(showTimes(m)) + case findWordMsg: + return taken(m.askFind()) + + case matchMsg: + return taken(m.goToMatch(int(msg))) + case reopenMsg: return taken(m.openStream())