diff --git a/aec.go b/aec.go index 3b1652a..886d6c7 100644 --- a/aec.go +++ b/aec.go @@ -1,13 +1,13 @@ package aec -import "fmt" +import "strconv" // EraseMode is listed in a variable EraseModes. type EraseMode uint var ( // EraseModes is a list of EraseMode. - EraseModes struct { + EraseModes = struct { // All erase all. All EraseMode @@ -16,124 +16,143 @@ var ( // Tail erase to tail. Tail EraseMode + }{ + Tail: 0, + Head: 1, + All: 2, } - // Save saves the cursor position. - Save ANSI + // Save saves the cursor position. It uses both SCO ("ESC[s") and DEC + // ("ESC7") sequences as those were never standardized as part of the + // ANSI. + Save ANSI = newAnsi(esc + "s" + "\x1b7") - // Restore restores the cursor position. - Restore ANSI + // Restore restores the cursor position. It uses both SCO ("ESC[u") and + // DEC ("ESC8") sequences as those were never standardized as part of + // the ANSI. + Restore ANSI = newAnsi(esc + "u" + "\x1b8") // Hide hides the cursor. - Hide ANSI + Hide ANSI = newAnsi(esc + "?25l") // Show shows the cursor. - Show ANSI + Show ANSI = newAnsi(esc + "?25h") // Report reports the cursor position. - Report ANSI + Report ANSI = newAnsi(esc + "6n") ) -// Up moves up the cursor. -func Up(n uint) ANSI { - if n == 0 { - return empty - } - return newAnsi(fmt.Sprintf(esc+"%dA", n)) -} +// Up moves the cursor up by n positions (CUU). +func Up(n uint) ANSI { return csiUintDefault1(n, 'A') } -// Down moves down the cursor. -func Down(n uint) ANSI { - if n == 0 { - return empty - } - return newAnsi(fmt.Sprintf(esc+"%dB", n)) -} +// Down moves the cursor down by n positions (CUD). +func Down(n uint) ANSI { return csiUintDefault1(n, 'B') } -// Right moves right the cursor. -func Right(n uint) ANSI { - if n == 0 { - return empty - } - return newAnsi(fmt.Sprintf(esc+"%dC", n)) -} +// Right moves the cursor right by n positions (CUF). +func Right(n uint) ANSI { return csiUintDefault1(n, 'C') } -// Left moves left the cursor. -func Left(n uint) ANSI { - if n == 0 { - return empty - } - return newAnsi(fmt.Sprintf(esc+"%dD", n)) -} +// Left moves the cursor left by n positions (CUB). +func Left(n uint) ANSI { return csiUintDefault1(n, 'D') } -// NextLine moves down the cursor to head of a line. -func NextLine(n uint) ANSI { - if n == 0 { - return empty - } - return newAnsi(fmt.Sprintf(esc+"%dE", n)) -} +// NextLine moves the cursor down n lines and to the beginning of the line (CNL). +func NextLine(n uint) ANSI { return csiUintDefault1(n, 'E') } -// PreviousLine moves up the cursor to head of a line. -func PreviousLine(n uint) ANSI { - if n == 0 { - return empty - } - return newAnsi(fmt.Sprintf(esc+"%dF", n)) -} +// PreviousLine moves the cursor up n lines and to the beginning of the line (CPL). +func PreviousLine(n uint) ANSI { return csiUintDefault1(n, 'F') } -// Column set the cursor position to a given column. +// Column sets the cursor position to a given column (CHA). func Column(col uint) ANSI { - return newAnsi(fmt.Sprintf(esc+"%dG", col)) + if col <= 1 { + return newAnsi(esc + "G") + } + return csiUint(col, 'G') } -// Position set the cursor position to a given absolute position. +// Position sets the cursor position to a given absolute position (CUP). func Position(row, col uint) ANSI { - return newAnsi(fmt.Sprintf(esc+"%d;%dH", row, col)) + // According to ECMA-48 (ISO/IEC 6429), cursor positioning (CUP) uses 1-based + // coordinates with default values of 1;1. Empty or zero parameters are treated + // as defaults, so 0;0 is effectively interpreted as 1;1 (home position). + if row == 0 { + row = 1 + } + if col == 0 { + col = 1 + } + if row == 1 && col == 1 { + // ESC[0;0H == ESC[1;1H == ESC[H + return newAnsi(esc + "H") + } + buf := make([]byte, 0, len(esc)+42) + buf = append(buf, esc...) + buf = strconv.AppendUint(buf, uint64(row), 10) + buf = append(buf, ';') + buf = strconv.AppendUint(buf, uint64(col), 10) + buf = append(buf, 'H') + return newAnsi(string(buf)) } -// EraseDisplay erases display by given EraseMode. +// EraseDisplay erases the display using the given EraseMode (ED). func EraseDisplay(m EraseMode) ANSI { - return newAnsi(fmt.Sprintf(esc+"%dJ", m)) + if m == 0 { + return newAnsi(esc + "J") + } + return csiUint(uint(m), 'J') } -// EraseLine erases lines by given EraseMode. +// EraseLine erases the line using the given EraseMode (EL). func EraseLine(m EraseMode) ANSI { - return newAnsi(fmt.Sprintf(esc+"%dK", m)) + if m == 0 { + return newAnsi(esc + "K") + } + return csiUint(uint(m), 'K') } -// ScrollUp scrolls up the page. +// ScrollUp scrolls the display up by n lines (SU). n <= 0 is a no-op. func ScrollUp(n int) ANSI { - if n == 0 { - return empty - } - return newAnsi(fmt.Sprintf(esc+"%dS", n)) + return scroll(n, 'S') } -// ScrollDown scrolls down the page. +// ScrollDown scrolls the display down by n lines (SD). n <= 0 is a no-op. func ScrollDown(n int) ANSI { - if n == 0 { + return scroll(n, 'T') +} + +func scroll(n int, direction byte) ANSI { + switch { + case n <= 0: + // Negative counts are not defined for SU/SD; treat them as a no-op. return empty + case n == 1: + return newAnsi(esc + string(direction)) + default: + return csiInt(n, direction) } - return newAnsi(fmt.Sprintf(esc+"%dT", n)) } -func init() { - EraseModes = struct { - All EraseMode - Head EraseMode - Tail EraseMode - }{ - Tail: 0, - Head: 1, - All: 2, +func csiInt(n int, suffix byte) ANSI { + buf := make([]byte, 0, len(esc)+21) + buf = append(buf, esc...) + buf = strconv.AppendInt(buf, int64(n), 10) + buf = append(buf, suffix) + return newAnsi(string(buf)) +} + +func csiUintDefault1(n uint, direction byte) ANSI { + switch n { + case 0: + return empty + case 1: + return newAnsi(esc + string(direction)) + default: + return csiUint(n, direction) } +} - // Save use both SCO (ESC[s) and DEC (ESC7) sequences as those were never standardised as part of the ANSI - Save = newAnsi(esc + "s" + "\x1b7") - // Restore use both SCO (ESC[u) and DEC (ESC8) and DEC sequences as those were never standardised as part of the ANSI - Restore = newAnsi(esc + "u" + "\x1b8") - Hide = newAnsi(esc + "?25l") - Show = newAnsi(esc + "?25h") - Report = newAnsi(esc + "6n") +func csiUint(n uint, suffix byte) ANSI { + buf := make([]byte, 0, len(esc)+21) + buf = append(buf, esc...) + buf = strconv.AppendUint(buf, uint64(n), 10) + buf = append(buf, suffix) + return newAnsi(string(buf)) } diff --git a/aec_test.go b/aec_test.go new file mode 100644 index 0000000..801c8ce --- /dev/null +++ b/aec_test.go @@ -0,0 +1,71 @@ +package aec_test + +import ( + "fmt" + "testing" + + "github.com/morikuni/aec" +) + +var benchValues = []struct { + n uint +}{ + {n: 0}, // 0,1 special case + {n: 2}, // 1 digit + {n: 20}, // 2 digit + {n: 200}, // 3 digits +} + +// Representative benchmark for all single-parameter uint CSI generators: +// Up, Down, Left, Right, NextLine, PreviousLine, Column, EraseDisplay, and EraseLine. +func BenchmarkCursor(b *testing.B) { + for _, tc := range benchValues { + b.Run(fmt.Sprintf("%d", tc.n), func(b *testing.B) { + var a aec.ANSI + b.ReportAllocs() + for i := 0; i < b.N; i++ { + a = aec.Up(tc.n) + } + sinkANSI = a + }) + } +} + +// Covers the two-parameter uint CSI path used by [aec.Position]. +func BenchmarkPosition(b *testing.B) { + var tests = []struct { + row uint + col uint + }{ + {row: 0, col: 0}, // 0,1 special case + {row: 2, col: 2}, // 1 digit + {row: 20, col: 20}, // 2 digit + {row: 200, col: 200}, // 3 digits + {row: 5, col: 15}, + } + for _, tc := range tests { + b.Run(fmt.Sprintf("%d,%d", tc.row, tc.col), func(b *testing.B) { + var a aec.ANSI + b.ReportAllocs() + for i := 0; i < b.N; i++ { + a = aec.Position(tc.row, tc.col) + } + sinkANSI = a + }) + } +} + +// Representative benchmark for all single-parameter int CSI +// generators ([aec.ScrollUp], [aec.ScrollDown]). +func BenchmarkScrollUpDown(b *testing.B) { + for _, tc := range benchValues { + b.Run(fmt.Sprintf("%d", tc.n), func(b *testing.B) { + var a aec.ANSI + b.ReportAllocs() + for i := 0; i < b.N; i++ { + a = aec.ScrollUp(int(tc.n)) + } + sinkANSI = a + }) + } +} diff --git a/ansi.go b/ansi.go index e60722e..c44fccc 100644 --- a/ansi.go +++ b/ansi.go @@ -8,7 +8,7 @@ import ( const esc = "\x1b[" // Reset resets SGR effect. -const Reset string = "\x1b[0m" +const Reset = "\x1b[0m" var empty = newAnsi("") @@ -23,6 +23,7 @@ type ANSI interface { Apply(string) string } +// ansiImpl represents an ANSI escape code. type ansiImpl string func newAnsi(s string) *ansiImpl { @@ -30,19 +31,22 @@ func newAnsi(s string) *ansiImpl { return &r } +// With returns a new ANSICode sequence composed of this and the provided ANSI codes. func (a *ansiImpl) With(ansi ...ANSI) ANSI { return concat(append([]ANSI{a}, ansi...)) } +// Apply wraps the given string with the ANSI sequence and a reset code. func (a *ansiImpl) Apply(s string) string { return a.String() + s + Reset } +// String returns the ANSICode escape code as a string. func (a *ansiImpl) String() string { return string(*a) } -// Apply wraps given string in ANSIs. +// Apply wraps the given string with all provided ANSI sequences. func Apply(s string, ansi ...ANSI) string { if len(ansi) == 0 { return s @@ -50,6 +54,7 @@ func Apply(s string, ansi ...ANSI) string { return concat(ansi).Apply(s) } +// concat combines multiple ANSI codes into a single ANSI sequence. func concat(ansi []ANSI) ANSI { strs := make([]string, 0, len(ansi)) for _, p := range ansi { diff --git a/builder.go b/builder.go index 13bd002..9c64d4b 100644 --- a/builder.go +++ b/builder.go @@ -6,7 +6,7 @@ type Builder struct { } // EmptyBuilder is an initialized Builder. -var EmptyBuilder *Builder +var EmptyBuilder = &Builder{empty} // NewBuilder creates a Builder from existing ANSI. func NewBuilder(a ...ANSI) *Builder { @@ -382,7 +382,3 @@ func (builder *Builder) RGB8BitF(r, g, b uint8) *Builder { func (builder *Builder) RGB8BitB(r, g, b uint8) *Builder { return builder.Color8BitB(NewRGB8Bit(r, g, b)) } - -func init() { - EmptyBuilder = &Builder{empty} -} diff --git a/sgr.go b/sgr.go index 0ba3464..95d3d62 100644 --- a/sgr.go +++ b/sgr.go @@ -1,8 +1,6 @@ package aec -import ( - "fmt" -) +import "strconv" // RGB3Bit is a 3bit RGB color. type RGB3Bit uint8 @@ -10,8 +8,30 @@ type RGB3Bit uint8 // RGB8Bit is a 8bit RGB color. type RGB8Bit uint8 -func newSGR(n uint) ANSI { - return newAnsi(fmt.Sprintf(esc+"%dm", n)) +func newSGR(n uint8) ANSI { + return sgr1("", n) +} + +func sgr1(prefix string, n uint8) ANSI { + buf := make([]byte, 0, len(esc)+len(prefix)+3+1) + buf = append(buf, esc...) + buf = append(buf, prefix...) + buf = strconv.AppendUint(buf, uint64(n), 10) + buf = append(buf, 'm') + return newAnsi(string(buf)) +} + +func newFullColor(prefix string, a, b, c uint8) ANSI { + buf := make([]byte, 0, len(esc)+len(prefix)+3+1+3+1+3+1) + buf = append(buf, esc...) + buf = append(buf, prefix...) + buf = strconv.AppendUint(buf, uint64(a), 10) + buf = append(buf, ';') + buf = strconv.AppendUint(buf, uint64(b), 10) + buf = append(buf, ';') + buf = strconv.AppendUint(buf, uint64(c), 10) + buf = append(buf, 'm') + return newAnsi(string(buf)) } // NewRGB3Bit create a RGB3Bit from given RGB. @@ -26,177 +46,121 @@ func NewRGB8Bit(r, g, b uint8) RGB8Bit { // Color3BitF set the foreground color of text. func Color3BitF(c RGB3Bit) ANSI { - return newAnsi(fmt.Sprintf(esc+"%dm", c+30)) + return newSGR(uint8(c + 30)) } // Color3BitB set the background color of text. func Color3BitB(c RGB3Bit) ANSI { - return newAnsi(fmt.Sprintf(esc+"%dm", c+40)) + return newSGR(uint8(c + 40)) } // Color8BitF set the foreground color of text. func Color8BitF(c RGB8Bit) ANSI { - return newAnsi(fmt.Sprintf(esc+"38;5;%dm", c)) + return sgr1("38;5;", uint8(c)) } // Color8BitB set the background color of text. func Color8BitB(c RGB8Bit) ANSI { - return newAnsi(fmt.Sprintf(esc+"48;5;%dm", c)) + return sgr1("48;5;", uint8(c)) } // FullColorF set the foreground color of text. func FullColorF(r, g, b uint8) ANSI { - return newAnsi(fmt.Sprintf(esc+"38;2;%d;%d;%dm", r, g, b)) + return newFullColor("38;2;", r, g, b) } -// FullColorB set the foreground color of text. +// FullColorB set the background color of text. func FullColorB(r, g, b uint8) ANSI { - return newAnsi(fmt.Sprintf(esc+"48;2;%d;%d;%dm", r, g, b)) + return newFullColor("48;2;", r, g, b) } // Style var ( // Bold set the text style to bold or increased intensity. - Bold ANSI + Bold ANSI = newSGR(1) // Faint set the text style to faint. - Faint ANSI + Faint ANSI = newSGR(2) // Italic set the text style to italic. - Italic ANSI + Italic ANSI = newSGR(3) // Underline set the text style to underline. - Underline ANSI + Underline ANSI = newSGR(4) // BlinkSlow set the text style to slow blink. - BlinkSlow ANSI + BlinkSlow ANSI = newSGR(5) // BlinkRapid set the text style to rapid blink. - BlinkRapid ANSI + BlinkRapid ANSI = newSGR(6) // Inverse swap the foreground color and background color. - Inverse ANSI + Inverse ANSI = newSGR(7) // Conceal set the text style to conceal. - Conceal ANSI + Conceal ANSI = newSGR(8) // CrossOut set the text style to crossed out. - CrossOut ANSI + CrossOut ANSI = newSGR(9) // Frame set the text style to framed. - Frame ANSI + Frame ANSI = newSGR(51) // Encircle set the text style to encircled. - Encircle ANSI + Encircle ANSI = newSGR(52) // Overline set the text style to overlined. - Overline ANSI + Overline ANSI = newSGR(53) ) // Foreground color of text. var ( - // DefaultF is the default color of foreground. - DefaultF ANSI + // DefaultF is the default foreground color. + DefaultF ANSI = newSGR(39) // Normal color - BlackF ANSI - RedF ANSI - GreenF ANSI - YellowF ANSI - BlueF ANSI - MagentaF ANSI - CyanF ANSI - WhiteF ANSI + BlackF ANSI = newSGR(30) + RedF ANSI = newSGR(31) + GreenF ANSI = newSGR(32) + YellowF ANSI = newSGR(33) + BlueF ANSI = newSGR(34) + MagentaF ANSI = newSGR(35) + CyanF ANSI = newSGR(36) + WhiteF ANSI = newSGR(37) // Light color - LightBlackF ANSI - LightRedF ANSI - LightGreenF ANSI - LightYellowF ANSI - LightBlueF ANSI - LightMagentaF ANSI - LightCyanF ANSI - LightWhiteF ANSI + LightBlackF ANSI = newSGR(90) + LightRedF ANSI = newSGR(91) + LightGreenF ANSI = newSGR(92) + LightYellowF ANSI = newSGR(93) + LightBlueF ANSI = newSGR(94) + LightMagentaF ANSI = newSGR(95) + LightCyanF ANSI = newSGR(96) + LightWhiteF ANSI = newSGR(97) ) // Background color of text. var ( - // DefaultB is the default color of background. - DefaultB ANSI + // DefaultB is the default background color. + DefaultB ANSI = newSGR(49) // Normal color - BlackB ANSI - RedB ANSI - GreenB ANSI - YellowB ANSI - BlueB ANSI - MagentaB ANSI - CyanB ANSI - WhiteB ANSI + BlackB ANSI = newSGR(40) + RedB ANSI = newSGR(41) + GreenB ANSI = newSGR(42) + YellowB ANSI = newSGR(43) + BlueB ANSI = newSGR(44) + MagentaB ANSI = newSGR(45) + CyanB ANSI = newSGR(46) + WhiteB ANSI = newSGR(47) // Light color - LightBlackB ANSI - LightRedB ANSI - LightGreenB ANSI - LightYellowB ANSI - LightBlueB ANSI - LightMagentaB ANSI - LightCyanB ANSI - LightWhiteB ANSI + LightBlackB ANSI = newSGR(100) + LightRedB ANSI = newSGR(101) + LightGreenB ANSI = newSGR(102) + LightYellowB ANSI = newSGR(103) + LightBlueB ANSI = newSGR(104) + LightMagentaB ANSI = newSGR(105) + LightCyanB ANSI = newSGR(106) + LightWhiteB ANSI = newSGR(107) ) - -func init() { - Bold = newSGR(1) - Faint = newSGR(2) - Italic = newSGR(3) - Underline = newSGR(4) - BlinkSlow = newSGR(5) - BlinkRapid = newSGR(6) - Inverse = newSGR(7) - Conceal = newSGR(8) - CrossOut = newSGR(9) - - BlackF = newSGR(30) - RedF = newSGR(31) - GreenF = newSGR(32) - YellowF = newSGR(33) - BlueF = newSGR(34) - MagentaF = newSGR(35) - CyanF = newSGR(36) - WhiteF = newSGR(37) - - DefaultF = newSGR(39) - - BlackB = newSGR(40) - RedB = newSGR(41) - GreenB = newSGR(42) - YellowB = newSGR(43) - BlueB = newSGR(44) - MagentaB = newSGR(45) - CyanB = newSGR(46) - WhiteB = newSGR(47) - - DefaultB = newSGR(49) - - Frame = newSGR(51) - Encircle = newSGR(52) - Overline = newSGR(53) - - LightBlackF = newSGR(90) - LightRedF = newSGR(91) - LightGreenF = newSGR(92) - LightYellowF = newSGR(93) - LightBlueF = newSGR(94) - LightMagentaF = newSGR(95) - LightCyanF = newSGR(96) - LightWhiteF = newSGR(97) - - LightBlackB = newSGR(100) - LightRedB = newSGR(101) - LightGreenB = newSGR(102) - LightYellowB = newSGR(103) - LightBlueB = newSGR(104) - LightMagentaB = newSGR(105) - LightCyanB = newSGR(106) - LightWhiteB = newSGR(107) -} diff --git a/sgr_test.go b/sgr_test.go new file mode 100644 index 0000000..f908702 --- /dev/null +++ b/sgr_test.go @@ -0,0 +1,39 @@ +package aec_test + +import ( + "testing" + + "github.com/morikuni/aec" +) + +var sinkANSI aec.ANSI // used in benchmarks to avoid compiler optimizations + +func BenchmarkColor3BitF(b *testing.B) { + b.ReportAllocs() + + var a aec.ANSI + for i := 0; i < b.N; i++ { + a = aec.Color3BitF(3) + } + sinkANSI = a +} + +func BenchmarkColor8BitF(b *testing.B) { + b.ReportAllocs() + + var a aec.ANSI + for i := 0; i < b.N; i++ { + a = aec.Color8BitF(128) + } + sinkANSI = a +} + +func BenchmarkFullColorF(b *testing.B) { + b.ReportAllocs() + + var a aec.ANSI + for i := 0; i < b.N; i++ { + a = aec.FullColorF(255, 128, 0) + } + sinkANSI = a +}