diff --git a/get.go b/get.go index be0055c3..d0f0826e 100644 --- a/get.go +++ b/get.go @@ -370,6 +370,12 @@ func (s Style) GetMaxHeight() int { return s.getAsInt(maxHeightKey) } +// GetTabWidth returns the style's tab width setting. If no value is set 4 is +// returned which is the implicit default. +func (s Style) GetTabWidth() int { + return s.getAsInt(tabWidthKey) +} + // GetUnderlineSpaces returns whether or not the style is set to underline // spaces. If not value is set false is returned. func (s Style) GetUnderlineSpaces() bool { diff --git a/set.go b/set.go index 92e9f212..ee660f2b 100644 --- a/set.go +++ b/set.go @@ -14,7 +14,14 @@ func (s *Style) set(key propKey, value interface{}) { switch v := value.(type) { case int: - // We don't allow negative integers on any of our values, so just keep + // TabWidth is the only property that may have a negative value (and + // that negative value can be no less than -1). + if key == tabWidthKey { + s.rules[key] = v + break + } + + // We don't allow negative integers on any of our other values, so just keep // them at zero or above. We could use uints instead, but the // conversions are a little tedious, so we're sticking with ints for // sake of usability. @@ -497,13 +504,30 @@ func (s Style) MaxWidth(n int) Style { // styles. // // Because this in intended to be used at the time of render, this method will -// not mutate the style and instead return a copy. +// not mutate the style and instead returns a copy. func (s Style) MaxHeight(n int) Style { o := s.Copy() o.set(maxHeightKey, n) return o } +// NoTabConversion can be passed to [Style.TabWidth] to disable the replacement +// of tabs with spaces at render time. +const NoTabConversion = -1 + +// TabWidth sets the number of spaces that a tab (/t) should be rendered as. +// When set to 0, tabs will be removed. To disable the replacement of tabs with +// spaces entirely, set this to [NoTabConversion]. +// +// By default, tabs will be replaced with 4 spaces. +func (s Style) TabWidth(n int) Style { + if n <= -1 { + n = -1 + } + s.set(tabWidthKey, n) + return s +} + // UnderlineSpaces determines whether to underline spaces between words. By // default, this is true. Spaces can also be underlined without underlining the // text itself. diff --git a/style.go b/style.go index e94b8670..9993d9c7 100644 --- a/style.go +++ b/style.go @@ -10,6 +10,8 @@ import ( "github.com/muesli/termenv" ) +const tabWidthDefault = 4 + // Property for a key. type propKey int @@ -68,6 +70,7 @@ const ( inlineKey maxWidthKey maxHeightKey + tabWidthKey underlineSpacesKey strikethroughSpacesKey ) @@ -224,7 +227,7 @@ func (s Style) Render(strs ...string) string { ) if len(s.rules) == 0 { - return str + return s.maybeConvertTabs(str) } // Enable support for ANSI on the legacy Windows cmd.exe console. This is a @@ -287,6 +290,9 @@ func (s Style) Render(strs ...string) string { teSpace = teSpace.CrossOut() } + // Potentially convert tabs to spaces + str = s.maybeConvertTabs(str) + // Strip newlines in single line mode if inline { str = strings.ReplaceAll(str, "\n", "") @@ -397,6 +403,21 @@ func (s Style) Render(strs ...string) string { return str } +func (s Style) maybeConvertTabs(str string) string { + tw := tabWidthDefault + if s.isSet(tabWidthKey) { + tw = s.getAsInt(tabWidthKey) + } + switch tw { + case -1: + return str + case 0: + return strings.ReplaceAll(str, "\t", "") + default: + return strings.ReplaceAll(str, "\t", strings.Repeat(" ", tw)) + } +} + func (s Style) applyMargins(str string, inline bool) string { var ( topMargin = s.getAsInt(marginTopKey) diff --git a/style_test.go b/style_test.go index 697e7d19..fd9c8aac 100644 --- a/style_test.go +++ b/style_test.go @@ -181,7 +181,8 @@ func TestStyleCopy(t *testing.T) { Foreground(Color("#ffffff")). Background(Color("#111111")). Margin(1, 1, 1, 1). - Padding(1, 1, 1, 1) + Padding(1, 1, 1, 1). + TabWidth(2) i := s.Copy() @@ -202,6 +203,7 @@ func TestStyleCopy(t *testing.T) { requireEqual(t, s.GetPaddingRight(), i.GetPaddingRight()) requireEqual(t, s.GetPaddingTop(), i.GetPaddingTop()) requireEqual(t, s.GetPaddingBottom(), i.GetPaddingBottom()) + requireEqual(t, s.GetTabWidth(), i.GetTabWidth()) } func TestStyleUnset(t *testing.T) { @@ -312,6 +314,12 @@ func TestStyleUnset(t *testing.T) { requireTrue(t, s.GetBorderLeft()) s.UnsetBorderLeft() requireFalse(t, s.GetBorderLeft()) + + // tab width + s = NewStyle().TabWidth(2) + requireEqual(t, s.GetTabWidth(), 2) + s.UnsetTabWidth() + requireNotEqual(t, s.GetTabWidth(), 4) } func TestStyleValue(t *testing.T) { @@ -352,7 +360,17 @@ func TestStyleValue(t *testing.T) { res, formatEscapes(res)) } } +} +func TestTabConversion(t *testing.T) { + s := NewStyle() + requireEqual(t, "[ ]", s.Render("[\t]")) + s = NewStyle().TabWidth(2) + requireEqual(t, "[ ]", s.Render("[\t]")) + s = NewStyle().TabWidth(0) + requireEqual(t, "[]", s.Render("[\t]")) + s = NewStyle().TabWidth(-1) + requireEqual(t, "[\t]", s.Render("[\t]")) } func BenchmarkStyleRender(b *testing.B) { diff --git a/unset.go b/unset.go index 4f8fe658..f889f9e2 100644 --- a/unset.go +++ b/unset.go @@ -287,6 +287,12 @@ func (s Style) UnsetMaxHeight() Style { return s } +// UnsetMaxHeight removes the max height style rule, if set. +func (s Style) UnsetTabWidth() Style { + delete(s.rules, tabWidthKey) + return s +} + // UnsetUnderlineSpaces removes the value set by UnderlineSpaces. func (s Style) UnsetUnderlineSpaces() Style { delete(s.rules, underlineSpacesKey)