Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 35 additions & 2 deletions internal/tui/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"crypto/sha256"
"errors"
"fmt"
"io"
"os"
"os/exec"
"runtime"
Expand Down Expand Up @@ -147,6 +148,16 @@ type App struct {
// missing-TeX notice, so only a note's first LaTeX block carries it.
latexNoticeShown bool

// cursor is where the terminal cursor belongs in the last frame drawn.
cursor cursorState
// cursorOut shapes the terminal cursor; nil when the app runs without a
// terminal, as in tests.
cursorOut *cursorOut
// cmdlineX is the column of the next typed character on the command
// line, set by renderCommandLine while a command line is open.
cmdlineX int
cmdlineOpen bool

systemDark bool
exHistory []string
mouse mouseState
Expand Down Expand Up @@ -189,9 +200,20 @@ func Run(ctx context.Context, opts Options) error {
if prefs.TerminalMouse {
teaOpts = append(teaOpts, tea.WithMouseCellMotion())
}
return runProgram(app, os.Stdout, os.Stdin, teaOpts...)
}

// runProgram runs app on out and in with the cursor wrapper between Bubble Tea
// and the terminal. Bubble Tea recovers panics and p.Run returns an error, so
// the cursor is restored on every path after p.Run returns.
func runProgram(app *App, out cursorFile, in io.Reader, teaOpts ...tea.ProgramOption) error {
wrapper := newCursorOut(out)
app.cursorOut = wrapper
defer wrapper.Reset()
teaOpts = append([]tea.ProgramOption{tea.WithOutput(wrapper), tea.WithInput(in)}, teaOpts...)
p := tea.NewProgram(app, teaOpts...)
app.program = p
_, err = p.Run()
_, err := p.Run()
saveErr := app.shutdown()
if err == nil {
err = saveErr
Expand Down Expand Up @@ -314,7 +336,11 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
return a, tea.Batch(configPollCmd(), a.flush())
case tea.ResumeMsg:
a.resumeCursor()
return a, a.flush()
case configEditedMsg:
a.resumeCursor()
if m.err != nil {
a.notifyError(m.err.Error())
} else if err := a.reloadPreferences(); err != nil {
Expand Down Expand Up @@ -472,6 +498,7 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
a.invalidatePreviews()
return a, a.flush()
case editorDoneMsg:
a.resumeCursor()
a.finishExternalEdit(m)
return a, a.flush()
case leaderHintMsg:
Expand Down Expand Up @@ -714,6 +741,10 @@ func (a *App) View() string {
if len(a.toasts) > 0 {
screen = a.renderToasts(screen)
}
a.cursor = a.computeCursor()
if a.cursorOut != nil {
a.cursorOut.Set(a.cursor)
}
return a.theme.paint(screen)
}

Expand Down Expand Up @@ -1010,6 +1041,7 @@ func (a *App) vaultLabel() string {

func (a *App) renderCommandLine() string {
t := a.theme
a.cmdlineOpen = false
if buf := a.activeBuffer(); buf != nil && a.focus == focusPane {
if kind, text, cursor, active := buf.ed.Cmdline(); active {
runes := []rune(text)
Expand All @@ -1018,7 +1050,8 @@ func (a *App) renderCommandLine() string {
if cursor < len(runes) {
after = string(runes[cursor:])
}
line := string(kind) + before + t.Cursor.Render(firstCellOr(after, " ")) + restAfterFirst(after)
a.cmdlineX, a.cmdlineOpen = min(cellWidth(string(kind)+before), max(0, a.width-1)), true
line := string(kind) + before + after
if comps, idx := buf.ed.Completions(); len(comps) > 0 {
line += " " + a.renderWildmenu(comps, idx)
}
Expand Down
8 changes: 8 additions & 0 deletions internal/tui/buffers.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,14 @@ type noteBuffer struct {
afterLoad []func(*noteBuffer)
loadErr error
rows []rowInfo
// focusRows is the wrapping of the pane that last drew this buffer with
// focus; rows belongs to whichever pane drew it last.
focusRows []rowInfo
// focusW is the drawn width of that editor, gutter included.
focusW int
// standalone marks an editor drawn outside the tabs, such as quick
// capture's, whose cursor its host places.
standalone bool

words int
wordsVersion int
Expand Down
1 change: 1 addition & 0 deletions internal/tui/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -635,6 +635,7 @@ func (a *App) commandTable() []command {
return nil
}},
{names: []string{"suspend", "stop"}, title: "Suspend to the shell (fg to return)", palette: true, run: func(a *App, _ *noteBuffer, _ vim.ExCommand) error {
a.suspendCursor()
a.queue(tea.Suspend)
return nil
}},
Expand Down
120 changes: 120 additions & 0 deletions internal/tui/cursor.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package tui

import "github.com/ZenNotes/tui/internal/vim"

// bufferCursorShape is the terminal cursor shape the buffer wants while its
// text is being edited, or a normal-mode r waits for its character. ok is false
// when the buffer keeps the drawn block instead: in vim normal and visual mode,
// and while the note is still loading. It does not know about the command
// line; computeCursor handles that.
func (a *App) bufferCursorShape(buf *noteBuffer) (shape cursorShape, ok bool) {
if buf == nil || buf.loading {
return 0, false
}
if !a.prefs.VimMode {
return cursorShapeBar, true
}
switch buf.ed.Mode() {
case vim.ModeInsert:
return cursorShapeBar, true
case vim.ModeReplace:
return cursorShapeUnderline, true
}
if buf.ed.ReplacePending() {
return cursorShapeUnderline, true
}
return 0, false
}

// terminalCursorOwns reports whether the terminal's own cursor stands in for
// the drawn block in buf. Only the active tab's buffer is placed that way;
// a standalone editor, such as quick capture's, is placed by its host.
func (a *App) terminalCursorOwns(buf *noteBuffer) bool {
if buf == nil || (!buf.standalone && buf != a.activeBuffer()) {
return false
}
_, ok := a.bufferCursorShape(buf)
return ok
}

// insertionCell is the screen cell where the next typed character in buf
// lands: the pane's content origin, the gutter, and the width of the text
// before the cursor on its wrapped row, all taken from the focused pane's own
// layout. rc is the origin of the area buf was drawn in.
func (a *App) insertionCell(buf *noteBuffer, rc rect) (x, y int, ok bool) {
cur := buf.ed.Cursor()
line := buf.ed.LineRunes(cur.Line)
for _, row := range buf.focusRows {
if row.line != cur.Line || cur.Col < row.segStart || cur.Col > row.segEnd {
continue
}
if cur.Col == row.segEnd && row.segEnd < len(line) {
continue // the cursor starts the next wrapped row
}
x = rc.x + row.x + cellWidth(string(line[row.segStart:cur.Col]))
if buf.focusW > 0 {
x = min(x, rc.x+buf.focusW-1) // keep the bar inside the editor
}
return x, rc.y + row.y, true
}
return 0, 0, false
}

// computeCursor picks the cursor for the frame just drawn.
func (a *App) computeCursor() cursorState {
if len(a.toasts) > 0 {
return cursorState{} // a toast covers the corner it sits in
}
if a.leader != nil && a.leader.showHints {
return cursorState{} // the which-key popup covers the pane
}
if a.overlay != nil {
f, ok := a.overlay.(cursorField)
if !ok {
return cursorState{}
}
c := f.fieldCursor()
if c.visible {
c.x += a.overlayRect.x
c.y += a.overlayRect.y
}
return c
}
if a.focus != focusPane {
return cursorState{}
}
if a.cmdlineOpen {
return cursorState{visible: true, x: a.cmdlineX, y: a.height - 1, shape: cursorShapeBar}
}
t := a.activeTab()
if t == nil || t.view != nil || t.mode == modePreview {
return cursorState{}
}
buf := a.activeBuffer()
shape, ok := a.bufferCursorShape(buf)
if !ok {
return cursorState{}
}
x, y, ok := a.insertionCell(buf, a.contentRect(a.activePane))
if !ok {
return cursorState{}
}
return cursorState{visible: true, x: x, y: y, shape: shape}
}

// suspendCursor hands the terminal to something else (the shell or an
// external program): the cursor goes back to the terminal's default shape
// and the wrapper stops adding sequences until resumeCursor.
func (a *App) suspendCursor() {
if a.cursorOut != nil {
a.cursorOut.Reset()
a.cursorOut.Release()
}
}

// resumeCursor takes the terminal back and writes the cursor again.
func (a *App) resumeCursor() {
if a.cursorOut != nil {
a.cursorOut.Hold()
}
}
88 changes: 88 additions & 0 deletions internal/tui/cursor_cmdline_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package tui

import (
"strings"
"testing"

"github.com/charmbracelet/x/ansi"
)

// lastRow is the screen row of the command line.
func lastRow(a *App) int { return a.height - 1 }

func TestCmdlineCursorAfterText(t *testing.T) {
for _, kind := range []string{":", "/", "?"} {
for _, text := range []string{"", "wq"} {
a, buf := cursorApp(t, "hello\n", 80)
buf.ed.Feed("<Esc>" + kind + text)
view := a.View()
want := 1 + len(text)
if !a.cursor.visible || a.cursor.shape != cursorShapeBar ||
a.cursor.x != want || a.cursor.y != lastRow(a) {
t.Fatalf("%q %q: cursor = %+v, want a bar at (%d,%d)", kind, text, a.cursor, want, lastRow(a))
}
for _, c := range reverseCells(view) {
if c.y == lastRow(a) {
t.Fatalf("%q %q: reverse cell in the command line at %v", kind, text, c)
}
}
if got := ansi.Strip(strings.Split(view, "\n")[lastRow(a)]); !strings.HasPrefix(got, kind+text) {
t.Fatalf("command line = %q", got)
}
}
}
}

func TestCmdlineCursorMovedLeft(t *testing.T) {
cases := []struct {
name string
keys string
x int
on string
}{
{"left once", "<Left>", 1 + 2 + 2, "c"},
{"left twice, wide rune", "<Left><Left>", 1 + 2, "日"},
{"ctrl+b to the start", "<C-b>", 1, "a"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
a, buf := cursorApp(t, "hello\n", 80)
buf.ed.Feed("<Esc>:ab日c" + c.keys)
view := a.View()
if !a.cursor.visible || a.cursor.x != c.x || a.cursor.y != lastRow(a) {
t.Fatalf("cursor = %+v, want x %d on the last row", a.cursor, c.x)
}
if got := cellText2(view, a.cursor.x, a.cursor.y, cellWidth(c.on)); got != c.on {
t.Fatalf("bar on %q, want %q", got, c.on)
}
for _, cell := range reverseCells(view) {
if cell.y == lastRow(a) {
t.Fatalf("reverse cell in the command line at %v", cell)
}
}
})
}
}

func TestCmdlineCursorClosed(t *testing.T) {
for _, key := range []string{"<Esc>", "<CR>"} {
a, buf := cursorApp(t, "hello\n", 80)
buf.ed.Feed("<Esc>:" + key)
view := a.View()
if a.cursor.visible {
t.Fatalf("%s: cursor = %+v, want hidden", key, a.cursor)
}
if cells := reverseCells(view); len(cells) == 0 {
t.Fatalf("%s: no block cursor drawn in the buffer", key)
}
}
}

func TestCmdlineCursorWiderThanTerminal(t *testing.T) {
a, buf := cursorApp(t, "hello\n", 40)
buf.ed.Feed("<Esc>:" + strings.Repeat("a", 100))
a.View()
if a.cursor.visible && (a.cursor.x < 0 || a.cursor.x >= a.width) {
t.Fatalf("cursor = %+v, want a column inside the %d-wide terminal", a.cursor, a.width)
}
}
Loading
Loading