diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 1b04642..c719ee7 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -96,7 +96,10 @@ jobs: - name: Run golangci-lint uses: golangci/golangci-lint-action@v9 with: - version: v2.11.4 + version: v2.14.0 + # Build golangci-lint with the Go version from go.mod. Release + # binaries lag behind new Go versions and refuse to lint newer code. + install-mode: goinstall args: --timeout=3m - name: Lint summary diff --git a/area.go b/area.go index 5b26b5d..b8c29f1 100644 --- a/area.go +++ b/area.go @@ -1,135 +1,161 @@ package cursor import ( + "fmt" + "io" "os" "strings" + "sync" ) // Area displays content which can be updated on the fly. // You can use this to create live output, charts, dropdowns, etc. +// +// Create an Area with NewArea. All methods are safe for concurrent use, except +// WithWriter, which is meant to configure the Area before it is used. type Area struct { + mu *sync.Mutex height int - writer Writer + writer io.Writer cursor *Cursor cursorPosY int } -// NewArea returns a new Area. +// zeroAreaMu guards Areas that were not created with NewArea. +var zeroAreaMu sync.Mutex //nolint:gochecknoglobals // Fallback lock for zero value Areas. + +// NewArea returns a new Area that writes to os.Stdout. func NewArea() Area { return Area{ - height: 0, - writer: os.Stdout, - cursor: cursor, - cursorPosY: 0, + mu: &sync.Mutex{}, + writer: os.Stdout, + cursor: NewCursor(), } } -// WithWriter sets the custom writer. -func (area Area) WithWriter(writer Writer) Area { - area.writer = writer - area.cursor = area.cursor.WithWriter(writer) +// WithWriter returns a copy of the Area that writes its content and its cursor +// movements to writer. A nil writer is ignored. +func (area Area) WithWriter(writer io.Writer) Area { + area.mu = &sync.Mutex{} + + if writer != nil { + area.writer = writer + area.cursor = NewCursor().WithWriter(writer) + } return area } -// Clear clears the content of the Area. -func (area *Area) Clear() { - // Initialize writer if not done yet +// lock locks the Area and makes sure a zero value Area is usable. +func (area *Area) lock() func() { + mu := area.mu + if mu == nil { + mu = &zeroAreaMu + } + + mu.Lock() + if area.writer == nil { area.writer = os.Stdout } - if area.height > 0 { - area.Bottom() - area.ClearLinesUp(area.height) - area.StartOfLine() - } else { - area.StartOfLine() - area.cursor.ClearLine() + if area.cursor == nil { + area.cursor = NewCursor().WithWriter(area.writer) } + + return mu.Unlock +} + +// Clear clears the content of the Area. +func (area *Area) Clear() { + defer area.lock()() + + area.clear() } // Update overwrites the content of the Area and adjusts its height based on content. func (area *Area) Update(content string) { - area.Clear() - area.writeArea(content) + defer area.lock()() + + area.clear() + _, _ = fmt.Fprint(area.writer, normalizeNewlines(content)) area.cursorPosY = 0 area.height = strings.Count(content, "\n") } -// Up moves the cursor of the area up one line. +// Up moves the cursor of the area up n lines, but not above the top of the Area. func (area *Area) Up(n int) { - if n > 0 { - if area.cursorPosY+n > area.height { - n = area.height - area.cursorPosY - } + defer area.lock()() - area.cursor.Up(n) - area.cursorPosY += n - } + area.up(n) } -// Down moves the cursor of the area down one line. +// Down moves the cursor of the area down n lines, but not below the bottom of the Area. func (area *Area) Down(n int) { - if n > 0 { - if area.cursorPosY-n < 0 { - n = area.height - area.cursorPosY - } + defer area.lock()() - area.cursor.Down(n) - area.cursorPosY -= n - } + area.down(n) } -// Bottom moves the cursor to the bottom of the terminal. +// Bottom moves the cursor to the bottom of the area. // This is done by calculating how many lines were moved by Up and Down. func (area *Area) Bottom() { - if area.cursorPosY > 0 { - area.Down(area.cursorPosY) - area.cursorPosY = 0 - } + defer area.lock()() + + area.bottom() } // Top moves the cursor to the top of the area. // This is done by calculating how many lines were moved by Up and Down. func (area *Area) Top() { - if area.cursorPosY < area.height { - area.Up(area.height - area.cursorPosY) - area.cursorPosY = area.height - } + defer area.lock()() + + area.up(area.height - area.cursorPosY) } // StartOfLine moves the cursor to the start of the current line. func (area *Area) StartOfLine() { + defer area.lock()() + area.cursor.HorizontalAbsolute(0) } // StartOfLineDown moves the cursor down by n lines, then moves to cursor to the start of the line. func (area *Area) StartOfLineDown(n int) { - area.Down(n) - area.StartOfLine() + defer area.lock()() + + area.down(n) + area.cursor.HorizontalAbsolute(0) } // StartOfLineUp moves the cursor up by n lines, then moves to cursor to the start of the line. func (area *Area) StartOfLineUp(n int) { - area.Up(n) - area.StartOfLine() + defer area.lock()() + + area.up(n) + area.cursor.HorizontalAbsolute(0) } // UpAndClear moves the cursor up by n lines, then clears the line. func (area *Area) UpAndClear(n int) { - area.Up(n) + defer area.lock()() + + area.up(n) area.cursor.ClearLine() } // DownAndClear moves the cursor down by n lines, then clears the line. func (area *Area) DownAndClear(n int) { - area.Down(n) + defer area.lock()() + + area.down(n) area.cursor.ClearLine() } // Move moves the cursor relative by x and y. func (area *Area) Move(x, y int) { + defer area.lock()() + if x > 0 { area.cursor.Right(x) } else if x < 0 { @@ -137,28 +163,70 @@ func (area *Area) Move(x, y int) { } if y > 0 { - area.Up(y) + area.up(y) } else if y < 0 { - area.Down(-y) + area.down(-y) } } // ClearLinesUp clears n lines upwards from the current position and moves the cursor. func (area *Area) ClearLinesUp(n int) { - area.StartOfLine() - area.cursor.ClearLine() + defer area.lock()() - for i := 0; i < n; i++ { - area.UpAndClear(1) - } + area.clearLinesUp(n) } // ClearLinesDown clears n lines downwards from the current position and moves the cursor. func (area *Area) ClearLinesDown(n int) { - area.StartOfLine() + defer area.lock()() + + area.cursor.HorizontalAbsolute(0) area.cursor.ClearLine() - for i := 0; i < n; i++ { - area.DownAndClear(1) + for range n { + area.down(1) + area.cursor.ClearLine() + } +} + +func (area *Area) clear() { + if area.height > 0 { + area.bottom() + area.clearLinesUp(area.height) + area.cursor.HorizontalAbsolute(0) + } else { + area.cursor.HorizontalAbsolute(0) + area.cursor.ClearLine() } } + +func (area *Area) clearLinesUp(n int) { + area.cursor.HorizontalAbsolute(0) + area.cursor.ClearLine() + + for range n { + area.up(1) + area.cursor.ClearLine() + } +} + +// up moves the cursor up. cursorPosY counts the lines above the bottom of the Area. +func (area *Area) up(n int) { + n = min(n, area.height-area.cursorPosY) + if n > 0 { + area.cursor.Up(n) + area.cursorPosY += n + } +} + +func (area *Area) down(n int) { + n = min(n, area.cursorPosY) + if n > 0 { + area.cursor.Down(n) + area.cursorPosY -= n + } +} + +func (area *Area) bottom() { + area.down(area.cursorPosY) +} diff --git a/area_other.go b/area_other.go deleted file mode 100644 index 1e54413..0000000 --- a/area_other.go +++ /dev/null @@ -1,13 +0,0 @@ -//go:build !windows -// +build !windows - -package cursor - -import ( - "fmt" -) - -// Update overwrites the content of the Area and adjusts its height based on content. -func (area *Area) writeArea(content string) { - _, _ = fmt.Fprint(area.writer, content) -} diff --git a/area_test.go b/area_test.go new file mode 100644 index 0000000..5d53818 --- /dev/null +++ b/area_test.go @@ -0,0 +1,117 @@ +package cursor + +import ( + "os" + "strings" + "sync" + "testing" +) + +func TestAreaUpdate(t *testing.T) { + t.Parallel() + + var buf syncBuffer + + area := NewArea().WithWriter(&buf) + + area.Update("a\nb\n") + + if got, want := buf.take(), "\x1b[1G\x1b[2Ka\nb\n"; got != normalizeNewlines(want) { + t.Errorf("first update wrote %q, want %q", got, want) + } + + // The second update clears the two lines of the first one. + area.Update("c") + + want := "\x1b[1G\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[1Gc" + if got := buf.take(); got != want { + t.Errorf("second update wrote %q, want %q", got, want) + } +} + +func TestAreaMovementStaysInside(t *testing.T) { + t.Parallel() + + var buf syncBuffer + + area := NewArea().WithWriter(&buf) + area.Update("1\n2\n3\n") + buf.take() + + area.Up(10) + area.Up(1) + area.Down(1) + area.Down(10) + area.Top() + area.Bottom() + area.Down(1) + + want := "\x1b[3A\x1b[1B\x1b[2B\x1b[3A\x1b[3B" + if got := buf.take(); got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestAreaWithWriterDoesNotChangeGlobalCursor(t *testing.T) { + var global syncBuffer + + SetTarget(&global) + t.Cleanup(func() { SetTarget(os.Stdout) }) + + var buf syncBuffer + + area := NewArea().WithWriter(&buf) + area.Update("x\n") + area.Up(1) + + if got := global.take(); got != "" { + t.Errorf("area wrote %q to the global cursor", got) + } + + if !strings.Contains(buf.take(), "\x1b[1A") { + t.Error("area did not move its own cursor") + } +} + +func TestZeroValueArea(t *testing.T) { + var buf syncBuffer + + area := Area{writer: &buf} + area.Update("x\n") + area.Up(1) + + if got := buf.take(); !strings.HasSuffix(got, "\x1b[1A") { + t.Errorf("got %q", got) + } +} + +func TestAreaConcurrentUse(t *testing.T) { + t.Parallel() + + var buf syncBuffer + + area := NewArea().WithWriter(&buf) + + var wg sync.WaitGroup + + for i := range 8 { + wg.Go(func() { + for j := range 50 { + area.Update(strings.Repeat("line\n", (i+j)%4)) + area.Up(1) + area.Move(1, -1) + area.StartOfLineUp(1) + area.Top() + area.Bottom() + area.ClearLinesDown(1) + area.Clear() + } + }) + } + + wg.Wait() + + if buf.take() == "" { + t.Error("expected output") + } +} diff --git a/area_windows.go b/area_windows.go deleted file mode 100644 index de7dd29..0000000 --- a/area_windows.go +++ /dev/null @@ -1,21 +0,0 @@ -//go:build windows -// +build windows - -package cursor - -import ( - "fmt" -) - -// writeArea is a helper for platform dependant output. -// For Windows newlines '\n' in the content are replaced by '\r\n' -func (area *Area) writeArea(content string) { - last := ' ' - for _, r := range content { - if r == '\n' && last != '\r' { - fmt.Fprint(area.writer, "\r\n") - continue - } - fmt.Fprint(area.writer, string(r)) - } -} diff --git a/console_other.go b/console_other.go new file mode 100644 index 0000000..aa93d19 --- /dev/null +++ b/console_other.go @@ -0,0 +1,25 @@ +//go:build !windows + +package cursor + +import "io" + +// console is only implemented on Windows. +type console struct{} + +func (console) move(_, _ int) {} +func (console) horizontalAbsolute(_ int) {} +func (console) setVisible(_ bool) {} +func (console) clearLine() {} +func (console) clear() {} + +// legacyConsole reports whether w needs the Windows console API. Every terminal +// outside of Windows understands ANSI escape sequences. +func legacyConsole(_ io.Writer) (console, bool) { + return console{}, false +} + +// normalizeNewlines returns content unchanged outside of Windows. +func normalizeNewlines(content string) string { + return content +} diff --git a/console_windows.go b/console_windows.go new file mode 100644 index 0000000..b38757d --- /dev/null +++ b/console_windows.go @@ -0,0 +1,167 @@ +//go:build windows + +package cursor + +import ( + "io" + "strings" + "unsafe" + + "golang.org/x/sys/windows" +) + +// golang.org/x/sys/windows does not wrap these console functions. +var ( + kernel32 = windows.NewLazySystemDLL("kernel32.dll") + procGetConsoleCursorInfo = kernel32.NewProc("GetConsoleCursorInfo") + procSetConsoleCursorInfo = kernel32.NewProc("SetConsoleCursorInfo") + procFillConsoleOutputCharacter = kernel32.NewProc("FillConsoleOutputCharacterW") +) + +// consoleCursorInfo is CONSOLE_CURSOR_INFO. +type consoleCursorInfo struct { + size uint32 + visible int32 +} + +// console moves the cursor of a legacy Windows console through the console API. +type console struct { + handle windows.Handle +} + +// legacyConsole reports whether w is a Windows console that does not understand +// ANSI escape sequences. It first tries to turn on virtual terminal processing, +// which every Windows 10 and later console supports. +func legacyConsole(w io.Writer) (console, bool) { + f, ok := w.(interface{ Fd() uintptr }) + if !ok { + return console{}, false + } + + handle := windows.Handle(f.Fd()) + + var mode uint32 + if windows.GetConsoleMode(handle, &mode) != nil { + // Not a console, for example a file or a pipe. + return console{}, false + } + + if mode&windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING != 0 { + return console{}, false + } + + if windows.SetConsoleMode(handle, mode|windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING) == nil { + return console{}, false + } + + return console{handle: handle}, true +} + +func (c console) info() (windows.ConsoleScreenBufferInfo, bool) { + var info windows.ConsoleScreenBufferInfo + + return info, windows.GetConsoleScreenBufferInfo(c.handle, &info) == nil +} + +func (c console) move(x, y int) { + info, ok := c.info() + if !ok { + return + } + + pos := info.CursorPosition + pos.X = clamp(int(pos.X)+x, 0, int(info.Size.X)-1) + pos.Y = clamp(int(pos.Y)+y, 0, int(info.Size.Y)-1) + + _ = windows.SetConsoleCursorPosition(c.handle, pos) +} + +func (c console) horizontalAbsolute(n int) { + info, ok := c.info() + if !ok { + return + } + + pos := info.CursorPosition + pos.X = clamp(n, 0, int(info.Size.X)-1) + + _ = windows.SetConsoleCursorPosition(c.handle, pos) +} + +func (c console) setVisible(visible bool) { + var cursorInfo consoleCursorInfo + + ok, _, _ := procGetConsoleCursorInfo.Call(uintptr(c.handle), uintptr(unsafe.Pointer(&cursorInfo))) //nolint:gosec // Required by the Windows API call. + if ok == 0 { + return + } + + cursorInfo.visible = 0 + if visible { + cursorInfo.visible = 1 + } + + _, _, _ = procSetConsoleCursorInfo.Call(uintptr(c.handle), uintptr(unsafe.Pointer(&cursorInfo))) //nolint:gosec // Required by the Windows API call. +} + +func (c console) clearLine() { + info, ok := c.info() + if !ok { + return + } + + c.fill(windows.Coord{X: 0, Y: info.CursorPosition.Y}, int(info.Size.X)) +} + +func (c console) clear() { + info, ok := c.info() + if !ok { + return + } + + c.fill(info.CursorPosition, int(info.Size.X)-int(info.CursorPosition.X)) +} + +// fill writes length spaces starting at start without moving the cursor. +func (c console) fill(start windows.Coord, length int) { + if length <= 0 { + return + } + + var written uint32 + + // COORD is passed by value, packed into a single register. + coord := uintptr(uint16(start.X)) | uintptr(uint16(start.Y))<<16 //nolint:gosec // Bit packing, not a conversion of the value. + + _, _, _ = procFillConsoleOutputCharacter.Call(uintptr(c.handle), uintptr(' '), uintptr(length), coord, uintptr(unsafe.Pointer(&written))) //nolint:gosec // Required by the Windows API call. +} + +func clamp(v, lower, upper int) int16 { + return int16(max(lower, min(v, upper))) //nolint:gosec // Console coordinates are bounded by the int16 buffer size. +} + +// normalizeNewlines replaces every "\n" that is not part of "\r\n" with "\r\n", +// so multi-line content starts each line in the first column on Windows. +func normalizeNewlines(content string) string { + if !strings.Contains(content, "\n") { + return content + } + + var b strings.Builder + + b.Grow(len(content) + strings.Count(content, "\n")) + + var last rune + + for _, r := range content { + if r == '\n' && last != '\r' { + b.WriteByte('\r') + } + + b.WriteRune(r) + + last = r + } + + return b.String() +} diff --git a/cursor.go b/cursor.go index 89a3efc..77d6340 100644 --- a/cursor.go +++ b/cursor.go @@ -1,13 +1,22 @@ package cursor import ( + "fmt" + "io" "os" + "sync" ) -// Cursor displays content which can be updated on the fly. +// Cursor moves the terminal cursor of a writer. // You can use this to create live output, charts, dropdowns, etc. +// +// A Cursor writes ANSI escape sequences. On legacy Windows consoles that do not +// support them, it uses the Windows console API instead. +// +// All methods are safe for concurrent use. type Cursor struct { - writer Writer + mu sync.Mutex + writer io.Writer } // NewCursor creates a new Cursor instance writing to os.Stdout. @@ -15,12 +24,94 @@ func NewCursor() *Cursor { return &Cursor{writer: os.Stdout} } -// WithWriter allows for any arbitrary Writer to be used -// for cursor movement abstracted. -func (c *Cursor) WithWriter(w Writer) *Cursor { +// WithWriter sets the writer the cursor writes to and returns the cursor. +// A nil writer is ignored. +func (c *Cursor) WithWriter(w io.Writer) *Cursor { if w != nil { + c.mu.Lock() c.writer = w + c.mu.Unlock() } return c } + +// Up moves the cursor n lines up relative to the current position. +func (c *Cursor) Up(n int) { + if n > 0 { + c.do(func(con console) { con.move(0, -n) }, "\x1b[%dA", n) + } +} + +// Down moves the cursor n lines down relative to the current position. +func (c *Cursor) Down(n int) { + if n > 0 { + c.do(func(con console) { con.move(0, n) }, "\x1b[%dB", n) + } +} + +// Right moves the cursor n characters to the right relative to the current position. +func (c *Cursor) Right(n int) { + if n > 0 { + c.do(func(con console) { con.move(n, 0) }, "\x1b[%dC", n) + } +} + +// Left moves the cursor n characters to the left relative to the current position. +func (c *Cursor) Left(n int) { + if n > 0 { + c.do(func(con console) { con.move(-n, 0) }, "\x1b[%dD", n) + } +} + +// HorizontalAbsolute moves the cursor to n horizontally. +// The position n is absolute to the start of the line. +func (c *Cursor) HorizontalAbsolute(n int) { + n = max(n, 0) + + // ANSI columns start at 1. + c.do(func(con console) { con.horizontalAbsolute(n) }, "\x1b[%dG", n+1) +} + +// Show the cursor if it was hidden previously. +// Don't forget to show the cursor at least at the end of your application. +// Otherwise the user might have a terminal with a permanently hidden cursor, until they reopen the terminal. +func (c *Cursor) Show() { + c.do(func(con console) { con.setVisible(true) }, "\x1b[?25h") +} + +// Hide the cursor. +// Don't forget to show the cursor at least at the end of your application with Show. +// Otherwise the user might have a terminal with a permanently hidden cursor, until they reopen the terminal. +func (c *Cursor) Hide() { + c.do(func(con console) { con.setVisible(false) }, "\x1b[?25l") +} + +// ClearLine clears the current line. +func (c *Cursor) ClearLine() { + c.do(func(con console) { con.clearLine() }, "\x1b[2K") +} + +// Clear clears the line from the cursor position to its end. +func (c *Cursor) Clear() { + c.do(func(con console) { con.clear() }, "\x1b[K") +} + +// do runs legacy on legacy Windows consoles and writes the ANSI sequence otherwise. +func (c *Cursor) do(legacy func(console), format string, args ...any) { + c.mu.Lock() + defer c.mu.Unlock() + + w := c.writer + if w == nil { + w = os.Stdout + } + + if con, ok := legacyConsole(w); ok { + legacy(con) + + return + } + + _, _ = fmt.Fprintf(w, format, args...) +} diff --git a/cursor_other.go b/cursor_other.go deleted file mode 100644 index 90f97ed..0000000 --- a/cursor_other.go +++ /dev/null @@ -1,67 +0,0 @@ -//go:build !windows -// +build !windows - -package cursor - -import ( - "fmt" -) - -// Up moves the cursor n lines up relative to the current position. -func (c *Cursor) Up(n int) { - if n > 0 { - _, _ = fmt.Fprintf(c.writer, "\x1b[%dA", n) - } -} - -// Down moves the cursor n lines down relative to the current position. -func (c *Cursor) Down(n int) { - if n > 0 { - _, _ = fmt.Fprintf(c.writer, "\x1b[%dB", n) - } -} - -// Right moves the cursor n characters to the right relative to the current position. -func (c *Cursor) Right(n int) { - if n > 0 { - _, _ = fmt.Fprintf(c.writer, "\x1b[%dC", n) - } -} - -// Left moves the cursor n characters to the left relative to the current position. -func (c *Cursor) Left(n int) { - if n > 0 { - _, _ = fmt.Fprintf(c.writer, "\x1b[%dD", n) - } -} - -// HorizontalAbsolute moves the cursor to n horizontally. -// The position n is absolute to the start of the line. -func (c *Cursor) HorizontalAbsolute(n int) { - n++ // Moves the line to the character after n - _, _ = fmt.Fprintf(c.writer, "\x1b[%dG", n) -} - -// Show the cursor if it was hidden previously. -// Don't forget to show the cursor at least at the end of your application. -// Otherwise the user might have a terminal with a permanently hidden cursor, until they reopen the terminal. -func (c *Cursor) Show() { - _, _ = fmt.Fprint(c.writer, "\x1b[?25h") -} - -// Hide the cursor. -// Don't forget to show the cursor at least at the end of your application with Show. -// Otherwise the user might have a terminal with a permanently hidden cursor, until they reopen the terminal. -func (c *Cursor) Hide() { - _, _ = fmt.Fprintf(c.writer, "\x1b[?25l") -} - -// ClearLine clears the current line and moves the cursor to it's start position. -func (c *Cursor) ClearLine() { - _, _ = fmt.Fprintf(c.writer, "\x1b[2K") -} - -// Clear clears the current position and moves the cursor to the left. -func (c *Cursor) Clear() { - _, _ = fmt.Fprintf(c.writer, "\x1b[K") -} diff --git a/cursor_test.go b/cursor_test.go index a5a018e..7c9c292 100644 --- a/cursor_test.go +++ b/cursor_test.go @@ -1,36 +1,161 @@ package cursor import ( - "fmt" + "bytes" + "os" + "sync" "testing" ) -func TestHeightChanges(t *testing.T) { +// syncBuffer is a bytes.Buffer that is safe for concurrent use. +type syncBuffer struct { + mu sync.Mutex + buf bytes.Buffer +} + +func (b *syncBuffer) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + + return b.buf.Write(p) +} + +func (b *syncBuffer) take() string { + b.mu.Lock() + defer b.mu.Unlock() + + s := b.buf.String() + b.buf.Reset() + + return s +} + +func TestCursorWritesANSISequences(t *testing.T) { t.Parallel() - for i := 0; i < 4; i++ { - fmt.Println() - } + var buf syncBuffer - Up(3) + c := NewCursor().WithWriter(&buf) - if autoheight != 3 { - t.Errorf("height should be 3 but is %d", autoheight) + tests := []struct { + name string + do func() + want string + }{ + {"Up", func() { c.Up(2) }, "\x1b[2A"}, + {"Down", func() { c.Down(2) }, "\x1b[2B"}, + {"Right", func() { c.Right(2) }, "\x1b[2C"}, + {"Left", func() { c.Left(2) }, "\x1b[2D"}, + {"Up(0)", func() { c.Up(0) }, ""}, + {"Down(-1)", func() { c.Down(-1) }, ""}, + {"Hide", c.Hide, "\x1b[?25l"}, + {"Show", c.Show, "\x1b[?25h"}, + {"ClearLine", c.ClearLine, "\x1b[2K"}, + {"Clear", c.Clear, "\x1b[K"}, + {"HorizontalAbsolute", func() { c.HorizontalAbsolute(3) }, "\x1b[4G"}, } - Down(3) + for _, tt := range tests { + tt.do() - if autoheight != 0 { - t.Errorf("height should be 0 but is %d", autoheight) + if got := buf.take(); got != tt.want { + t.Errorf("%s wrote %q, want %q", tt.name, got, tt.want) + } } } -func TestHeightCannotBeNegative(t *testing.T) { +func TestCursorWithNilWriterKeepsWriter(t *testing.T) { t.Parallel() + var buf syncBuffer + + c := NewCursor().WithWriter(&buf).WithWriter(nil) + c.Up(1) + + if got := buf.take(); got != "\x1b[1A" { + t.Errorf("got %q, want %q", got, "\x1b[1A") + } +} + +// The global helpers share state, so these tests do not run in parallel. + +func useGlobalTarget(t *testing.T) *syncBuffer { + t.Helper() + + var buf syncBuffer + + SetTarget(&buf) + Bottom() + buf.take() + + t.Cleanup(func() { + Bottom() + SetTarget(os.Stdout) + }) + + return &buf +} + +func TestGlobalHelpers(t *testing.T) { + buf := useGlobalTarget(t) + + Up(3) + Down(1) + Move(2, -1) + Move(-2, 1) + HorizontalAbsolute(0) + ClearLine() + Hide() + Show() + + want := "\x1b[3A\x1b[1B\x1b[2C\x1b[1B\x1b[2D\x1b[1A\x1b[1G\x1b[2K\x1b[?25l\x1b[?25h" + if got := buf.take(); got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestGlobalHeightTracking(t *testing.T) { + buf := useGlobalTarget(t) + + Up(3) + Down(1) + Bottom() + + if got, want := buf.take(), "\x1b[3A\x1b[1B\x1b[2B\x1b[1G"; got != want { + t.Errorf("got %q, want %q", got, want) + } + + // Moving further down than up must not make the height negative. Down(10) + Bottom() + Up(1) + Bottom() + + if got, want := buf.take(), "\x1b[10B\x1b[1A\x1b[1B\x1b[1G"; got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestGlobalHelpersConcurrently(t *testing.T) { + buf := useGlobalTarget(t) + + var wg sync.WaitGroup + + for range 8 { + wg.Go(func() { + for range 50 { + Up(1) + Down(1) + Bottom() + ClearLinesUp(1) + ClearLinesDown(1) + } + }) + } + + wg.Wait() - if autoheight < 0 { - t.Errorf("height is negative: %d", autoheight) + if buf.take() == "" { + t.Error("expected output") } } diff --git a/cursor_test_linux.go b/cursor_test_linux.go deleted file mode 100644 index 52637f3..0000000 --- a/cursor_test_linux.go +++ /dev/null @@ -1,131 +0,0 @@ -package cursor - -import ( - "os" - "testing" -) - -// TestCustomIOWriter tests the cursor functions with a custom Writer. -func TestCustomIOWriter(t *testing.T) { - t.Parallel() - - tmpFile, err := os.CreateTemp("", "testingTmpFile-") - defer os.Remove(tmpFile.Name()) - - if err != nil { - t.Error(err) - } - - target := tmpFile - SetTarget(target) - - Up(2) - - expected := "\x1b[2A" - actual := getFileContent(t, target.Name()) - - if expected != actual { - t.Errorf("wanted: %v, got %v", expected, actual) - } - - clearFile(t, target) - Down(2) - - expected = "\x1b[2B" - actual = getFileContent(t, target.Name()) - - if expected != actual { - t.Errorf("wanted: %v, got %v", expected, actual) - } - - clearFile(t, target) - Right(2) - - expected = "\x1b[2C" - actual = getFileContent(t, target.Name()) - - if expected != actual { - t.Errorf("wanted: %v, got %v", expected, actual) - } - - clearFile(t, target) - Left(2) - - expected = "\x1b[2D" - actual = getFileContent(t, target.Name()) - - if expected != actual { - t.Errorf("wanted: %v, got %v", expected, actual) - } - - clearFile(t, target) - Hide() - - expected = "\x1b[?25l" - actual = getFileContent(t, target.Name()) - - if expected != actual { - t.Errorf("wanted: %v, got %v", expected, actual) - } - - clearFile(t, target) - Show() - - expected = "\x1b[?25h" - actual = getFileContent(t, target.Name()) - - if expected != actual { - t.Errorf("wanted: %v, got %v", expected, actual) - } - - clearFile(t, target) - ClearLine() - - expected = "\x1b[2K" - actual = getFileContent(t, target.Name()) - - if expected != actual { - t.Errorf("wanted: %v, got %v", expected, actual) - } - - clearFile(t, target) - HorizontalAbsolute(3) - - expected = "\x1b[4G" - actual = getFileContent(t, target.Name()) - - if expected != actual { - t.Errorf("wanted: %v, got %v", expected, actual) - } -} - -func getFileContent(t *testing.T, fileName string) string { - t.Helper() - - content, err := os.ReadFile(fileName) //nolint:gosec // Tests read temporary files they create. - if err != nil { - t.Errorf("failed to read file contents: %s", err) - - return "" - } - - return string(content) -} - -func clearFile(t *testing.T, file *os.File) { - t.Helper() - - err := file.Truncate(0) - if err != nil { - t.Errorf("failed to clear file") - - return - } - - _, err = file.Seek(0, 0) - if err != nil { - t.Errorf("failed to clear file") - - return - } -} diff --git a/cursor_windows.go b/cursor_windows.go deleted file mode 100644 index ebe2bc5..0000000 --- a/cursor_windows.go +++ /dev/null @@ -1,118 +0,0 @@ -//go:build windows -// +build windows - -package cursor - -import ( - "syscall" - "unsafe" -) - -// Up moves the cursor n lines up relative to the current position. -func (c *Cursor) Up(n int) { - c.move(0, -n) -} - -// Down moves the cursor n lines down relative to the current position. -func (c *Cursor) Down(n int) { - c.move(0, n) -} - -// Right moves the cursor n characters to the right relative to the current position. -func (c *Cursor) Right(n int) { - c.move(n, 0) -} - -// Left moves the cursor n characters to the left relative to the current position. -func (c *Cursor) Left(n int) { - c.move(-n, 0) -} - -func (c *Cursor) move(x int, y int) { - handle := syscall.Handle(c.writer.Fd()) - - var csbi consoleScreenBufferInfo - _, _, _ = procGetConsoleScreenBufferInfo.Call(uintptr(handle), uintptr(unsafe.Pointer(&csbi))) - - var cursor coord - cursor.x = csbi.cursorPosition.x + short(x) - cursor.y = csbi.cursorPosition.y + short(y) - - _, _, _ = procSetConsoleCursorPosition.Call(uintptr(handle), uintptr(*(*int32)(unsafe.Pointer(&cursor)))) -} - -// HorizontalAbsolute moves the cursor to n horizontally. -// The position n is absolute to the start of the line. -func (c *Cursor) HorizontalAbsolute(n int) { - handle := syscall.Handle(c.writer.Fd()) - - var csbi consoleScreenBufferInfo - _, _, _ = procGetConsoleScreenBufferInfo.Call(uintptr(handle), uintptr(unsafe.Pointer(&csbi))) - - var cursor coord - cursor.x = short(n) - cursor.y = csbi.cursorPosition.y - - if csbi.size.x < cursor.x { - cursor.x = csbi.size.x - } - - _, _, _ = procSetConsoleCursorPosition.Call(uintptr(handle), uintptr(*(*int32)(unsafe.Pointer(&cursor)))) -} - -// Show the cursor if it was hidden previously. -// Don't forget to show the cursor at least at the end of your application. -// Otherwise the user might have a terminal with a permanently hidden cursor, until he reopens the terminal. -func (c *Cursor) Show() { - handle := syscall.Handle(c.writer.Fd()) - - var cci consoleCursorInfo - _, _, _ = procGetConsoleCursorInfo.Call(uintptr(handle), uintptr(unsafe.Pointer(&cci))) - cci.visible = 1 - - _, _, _ = procSetConsoleCursorInfo.Call(uintptr(handle), uintptr(unsafe.Pointer(&cci))) -} - -// Hide the cursor. -// Don't forget to show the cursor at least at the end of your application with Show. -// Otherwise the user might have a terminal with a permanently hidden cursor, until he reopens the terminal. -func (c *Cursor) Hide() { - handle := syscall.Handle(c.writer.Fd()) - - var cci consoleCursorInfo - _, _, _ = procGetConsoleCursorInfo.Call(uintptr(handle), uintptr(unsafe.Pointer(&cci))) - cci.visible = 0 - - _, _, _ = procSetConsoleCursorInfo.Call(uintptr(handle), uintptr(unsafe.Pointer(&cci))) -} - -// ClearLine clears the current line and moves the cursor to its start position. -func (c *Cursor) ClearLine() { - handle := syscall.Handle(c.writer.Fd()) - - var csbi consoleScreenBufferInfo - _, _, _ = procGetConsoleScreenBufferInfo.Call(uintptr(handle), uintptr(unsafe.Pointer(&csbi))) - - var w uint32 - var x short - cursor := csbi.cursorPosition - x = csbi.size.x - _, _, _ = procFillConsoleOutputCharacter.Call(uintptr(handle), uintptr(' '), uintptr(x), uintptr(*(*int32)(unsafe.Pointer(&cursor))), uintptr(unsafe.Pointer(&w))) -} - -// Clear clears the current position and moves the cursor to the left. -func (c *Cursor) Clear() { - handle := syscall.Handle(c.writer.Fd()) - - var csbi consoleScreenBufferInfo - _, _, _ = procGetConsoleScreenBufferInfo.Call(uintptr(handle), uintptr(unsafe.Pointer(&csbi))) - - var w uint32 - cursor := csbi.cursorPosition - _, _, _ = procFillConsoleOutputCharacter.Call(uintptr(handle), uintptr(' '), uintptr(1), uintptr(*(*int32)(unsafe.Pointer(&cursor))), uintptr(unsafe.Pointer(&w))) - - if cursor.x > 0 { - cursor.x-- - } - _, _, _ = procSetConsoleCursorPosition.Call(uintptr(handle), uintptr(*(*int32)(unsafe.Pointer(&cursor)))) -} diff --git a/go.mod b/go.mod index d8a792d..28ac0b4 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,5 @@ module atomicgo.dev/cursor -go 1.15 +go 1.27.0 + +require golang.org/x/sys v0.49.0 diff --git a/go.sum b/go.sum index e69de29..d61accf 100644 --- a/go.sum +++ b/go.sum @@ -0,0 +1,2 @@ +golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= +golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= diff --git a/syscall_windows.go b/syscall_windows.go deleted file mode 100644 index d4bcdf7..0000000 --- a/syscall_windows.go +++ /dev/null @@ -1,43 +0,0 @@ -package cursor - -import ( - "syscall" -) - -var ( - kernel32 = syscall.NewLazyDLL("kernel32.dll") - procFillConsoleOutputCharacter = kernel32.NewProc("FillConsoleOutputCharacterW") - procGetConsoleCursorInfo = kernel32.NewProc("GetConsoleCursorInfo") - procGetConsoleScreenBufferInfo = kernel32.NewProc("GetConsoleScreenBufferInfo") - procSetConsoleCursorInfo = kernel32.NewProc("SetConsoleCursorInfo") - procSetConsoleCursorPosition = kernel32.NewProc("SetConsoleCursorPosition") -) - -type short int16 -type dword uint32 -type word uint16 - -type coord struct { - x short - y short -} - -type smallRect struct { - bottom short - left short - right short - top short -} - -type consoleScreenBufferInfo struct { - size coord - cursorPosition coord - attributes word - window smallRect - maximumWindowSize coord -} - -type consoleCursorInfo struct { - size dword - visible int32 -} diff --git a/utils.go b/utils.go index a842e45..1ee4a55 100644 --- a/utils.go +++ b/utils.go @@ -2,7 +2,7 @@ package cursor import ( "io" - "os" + "sync" ) // @@ -10,35 +10,52 @@ import ( // var ( - autoheight int //nolint:gochecknoglobals - cursor = &Cursor{writer: os.Stdout} //nolint:gochecknoglobals + cursor = NewCursor() //nolint:gochecknoglobals // The package level helpers share one cursor. + + // autoheightMu guards autoheight, the number of lines the global cursor was moved up. + autoheightMu sync.Mutex //nolint:gochecknoglobals // Guards autoheight. + autoheight int //nolint:gochecknoglobals // Used by Bottom. ) -// Writer is an expanded io.Writer interface with a file descriptor. +// Writer is an io.Writer with a file descriptor, such as os.Stdout. +// +// Cursor and Area accept any io.Writer. A file descriptor is only used on +// Windows, to move the cursor of legacy consoles that do not support ANSI +// escape sequences. type Writer interface { io.Writer Fd() uintptr } -// SetTarget sets to output target of the default curser to the -// provided cursor.Writer (wrapping io.Writer). -func SetTarget(w Writer) { - cursor = cursor.WithWriter(w) +// SetTarget sets the output target of the default cursor to the provided writer. +func SetTarget(w io.Writer) { + cursor.WithWriter(w) } // Up moves the cursor n lines up relative to the current position. func Up(n int) { + if n <= 0 { + return + } + + autoheightMu.Lock() + defer autoheightMu.Unlock() + cursor.Up(n) autoheight += n } // Down moves the cursor n lines down relative to the current position. func Down(n int) { - cursor.Down(n) - - if autoheight > 0 { - autoheight -= n + if n <= 0 { + return } + + autoheightMu.Lock() + defer autoheightMu.Unlock() + + cursor.Down(n) + autoheight = max(autoheight-n, 0) } // Right moves the cursor n characters to the right relative to the current position. @@ -71,12 +88,12 @@ func Hide() { cursor.Hide() } -// ClearLine clears the current line and moves the cursor to it's start position. +// ClearLine clears the current line. func ClearLine() { cursor.ClearLine() } -// Clear clears the current position and moves the cursor to the left. +// Clear clears the line from the cursor position to its end. func Clear() { cursor.Clear() } @@ -84,11 +101,13 @@ func Clear() { // Bottom moves the cursor to the bottom of the terminal. // This is done by calculating how many lines were moved by Up and Down. func Bottom() { - if autoheight > 0 { - Down(autoheight) - StartOfLine() + autoheightMu.Lock() + n := autoheight + autoheightMu.Unlock() - autoheight = 0 + if n > 0 { + Down(n) + StartOfLine() } } @@ -126,28 +145,26 @@ func Move(x, y int) { if x > 0 { Right(x) } else if x < 0 { - x *= -1 - Left(x) + Left(-x) } if y > 0 { Up(y) } else if y < 0 { - y *= -1 - Down(y) + Down(-y) } } // ClearLinesUp clears n lines upwards from the current position and moves the cursor. func ClearLinesUp(n int) { - for i := 0; i < n; i++ { + for range n { UpAndClear(1) } } // ClearLinesDown clears n lines downwards from the current position and moves the cursor. func ClearLinesDown(n int) { - for i := 0; i < n; i++ { + for range n { DownAndClear(1) } }