From cc5019ce478766f1be5f77a746766cb335293faa Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 04:12:58 +0000 Subject: [PATCH 1/2] feat!: make Area race-free and accept any io.Writer Area and Cursor are now safe for concurrent use. Every Area method locks a mutex, and the package level helpers guard their height tracking. An Area now owns its cursor, so Area.WithWriter no longer redirects the global cursor. Cursor, Area and SetTarget accept any io.Writer. They write ANSI escape sequences everywhere and only fall back to the Windows console API for legacy consoles that cannot enable virtual terminal processing. The Windows code uses golang.org/x/sys/windows. Also fix Area.Down clamping, the Windows newline conversion that doubled "\r", and cursor_test_linux.go, which was compiled into the library on Linux because of its name. Tests no longer share global state in parallel. BREAKING CHANGE: requires Go 1.27. SetTarget, Cursor.WithWriter and Area.WithWriter take an io.Writer instead of a cursor.Writer. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_011Dno7CsMx2fSzNx5ZTm7RL --- area.go | 194 +++++++++++++++++++++++++++++-------------- area_other.go | 13 --- area_test.go | 117 ++++++++++++++++++++++++++ area_windows.go | 21 ----- console_other.go | 25 ++++++ console_windows.go | 167 +++++++++++++++++++++++++++++++++++++ cursor.go | 101 ++++++++++++++++++++-- cursor_other.go | 67 --------------- cursor_test.go | 153 ++++++++++++++++++++++++++++++---- cursor_test_linux.go | 131 ----------------------------- cursor_windows.go | 118 -------------------------- go.mod | 4 +- go.sum | 2 + syscall_windows.go | 43 ---------- utils.go | 65 +++++++++------ 15 files changed, 721 insertions(+), 500 deletions(-) delete mode 100644 area_other.go create mode 100644 area_test.go delete mode 100644 area_windows.go create mode 100644 console_other.go create mode 100644 console_windows.go delete mode 100644 cursor_other.go delete mode 100644 cursor_test_linux.go delete mode 100644 cursor_windows.go delete mode 100644 syscall_windows.go 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) } } From d3d20f3daf6e9c781ad4fe9563ec12f050a6d070 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 04:33:49 +0000 Subject: [PATCH 2/2] ci: build golangci-lint with the Go version from go.mod The v2.11.4 release binary is built with Go 1.26 and refuses to lint a module that targets Go 1.27. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_011Dno7CsMx2fSzNx5ZTm7RL --- .github/workflows/go.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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