1
0

support custom bullets in lists and definition lists

This commit is contained in:
Arpad Ryszka 2025-11-09 16:53:20 +01:00
parent 78d3e0e284
commit cff44cc943
6 changed files with 538 additions and 11 deletions

29
lib.go
View File

@ -26,12 +26,19 @@ type Txt struct {
cat []Txt
}
type ItemStyle struct {
bullet string
noBullet bool
}
type ListItem struct {
text Txt
text Txt
style []ItemStyle
}
type DefinitionItem struct {
name, value Txt
style []ItemStyle
}
type TableCell struct {
@ -168,8 +175,18 @@ func Paragraph(t Txt) Entry {
return Entry{typ: paragraph, text: t}
}
func Item(text Txt) ListItem {
return ListItem{text: text}
func Bullet(b string) ItemStyle {
return ItemStyle{bullet: b}
}
func NoBullet() ItemStyle {
return ItemStyle{noBullet: true}
}
// Item creates a list item for bulleted or numbered lists. Item style may behave different depending ont the
// output format.
func Item(text Txt, style ...ItemStyle) ListItem {
return ListItem{text: text, style: style}
}
func List(items ...ListItem) Entry {
@ -180,8 +197,10 @@ func NumberedList(items ...ListItem) Entry {
return Entry{typ: numberedList, items: items}
}
func Definition(name, value Txt) DefinitionItem {
return DefinitionItem{name: name, value: value}
// Definition creates a definition list item for bulleted or numbered definition lists. Item style may behave
// different depending ont the output format.
func Definition(name, value Txt, style ...ItemStyle) DefinitionItem {
return DefinitionItem{name: name, value: value, style: style}
}
func DefinitionList(items ...DefinitionItem) Entry {

View File

@ -134,8 +134,24 @@ func renderRoffList(w io.Writer, e Entry) {
write(w, "\n.br\n")
}
write(w, ".in ", e.indent+2, "\n.ti ", e.indent+e.indentFirst, "\n")
write(w, "\\(bu ")
var (
bullet string
bulletIndent int
)
itemStyle := mergeItemStyles(item.style)
if !itemStyle.noBullet {
if itemStyle.bullet == "" {
bullet = "\\(bu "
bulletIndent = 2
} else {
bullet = itemStyle.bullet + " "
bulletIndent = len([]rune(bullet))
}
}
write(w, ".in ", e.indent+bulletIndent, "\n.ti ", e.indent+e.indentFirst, "\n")
write(w, bullet)
renderRoffText(w, item.text)
}
}
@ -161,8 +177,21 @@ func renderRoffDefinitions(w io.Writer, e Entry) {
write(w, "\n.br\n")
}
write(w, ".in ", e.indent+maxNameLength+4, "\n.ti ", e.indent+e.indentFirst, "\n")
write(w, "\\(bu ")
var bullet string
padLength := 2
itemStyle := mergeItemStyles(definition.style)
if !itemStyle.noBullet {
if itemStyle.bullet == "" {
bullet = "\\(bu "
padLength = 4
} else {
bullet = itemStyle.bullet + " "
padLength = len([]rune(bullet)) + 2
}
}
write(w, ".in ", e.indent+maxNameLength+padLength, "\n.ti ", e.indent+e.indentFirst, "\n")
write(w, bullet)
renderRoffText(w, definition.name)
write(w, ":", timesn("\\~", maxNameLength-len([]rune(names[i]))+1))
renderRoffText(w, definition.value)

View File

@ -904,6 +904,105 @@ This is a paragraph.
t.Fatal(b.String())
}
})
t.Run("custom bullet", func(t *testing.T) {
doc := textfmt.Doc(
textfmt.List(
textfmt.Item(textfmt.Text("foo bar baz"), textfmt.Bullet("*")),
textfmt.Item(textfmt.Text("qux"), textfmt.Bullet("*")),
textfmt.Item(textfmt.Text("quux"), textfmt.Bullet("*")),
),
)
var b bytes.Buffer
if err := textfmt.Runoff(&b, doc); err != nil {
t.Fatal(err)
}
const expect = `
.in 2
.ti 0
* foo bar baz
.br
.in 2
.ti 0
* qux
.br
.in 2
.ti 0
* quux
`
if "\n"+b.String() != expect {
t.Fatal("\n" + b.String())
}
})
t.Run("custom bullet longer", func(t *testing.T) {
doc := textfmt.Doc(
textfmt.List(
textfmt.Item(textfmt.Text("foo bar baz"), textfmt.Bullet("=>")),
textfmt.Item(textfmt.Text("qux"), textfmt.Bullet("=>")),
textfmt.Item(textfmt.Text("quux"), textfmt.Bullet("=>")),
),
)
var b bytes.Buffer
if err := textfmt.Runoff(&b, doc); err != nil {
t.Fatal(err)
}
const expect = `
.in 3
.ti 0
=> foo bar baz
.br
.in 3
.ti 0
=> qux
.br
.in 3
.ti 0
=> quux
`
if "\n"+b.String() != expect {
t.Fatal("\n" + b.String())
}
})
t.Run("no bullet", func(t *testing.T) {
doc := textfmt.Doc(
textfmt.List(
textfmt.Item(textfmt.Text("foo bar baz"), textfmt.NoBullet()),
textfmt.Item(textfmt.Text("qux"), textfmt.NoBullet()),
textfmt.Item(textfmt.Text("quux"), textfmt.NoBullet()),
),
)
var b bytes.Buffer
if err := textfmt.Runoff(&b, doc); err != nil {
t.Fatal(err)
}
const expect = `
.in 0
.ti 0
foo bar baz
.br
.in 0
.ti 0
qux
.br
.in 0
.ti 0
quux
`
if "\n"+b.String() != expect {
t.Fatal("\n" + b.String())
}
})
})
t.Run("numbered list", func(t *testing.T) {
@ -1261,6 +1360,141 @@ This is a paragraph.
.in 13
.ti 0
\(bu blue:\~\~looks like sky
`
if "\n"+b.String() != expect {
t.Fatal("\n" + b.String())
}
})
t.Run("custom bullet", func(t *testing.T) {
doc := textfmt.Doc(
textfmt.DefinitionList(
textfmt.Definition(
textfmt.Text("one"),
textfmt.Text("foo bar baz"),
textfmt.Bullet("*"),
),
textfmt.Definition(
textfmt.Text("two"),
textfmt.Text("qux"),
textfmt.Bullet("*"),
),
textfmt.Definition(
textfmt.Text("three"),
textfmt.Text("quux"),
textfmt.Bullet("*"),
),
),
)
var b bytes.Buffer
if err := textfmt.Runoff(&b, doc); err != nil {
t.Fatal(err)
}
const expect = `
.in 9
.ti 0
* one:\~\~\~foo bar baz
.br
.in 9
.ti 0
* two:\~\~\~qux
.br
.in 9
.ti 0
* three:\~quux
`
if "\n"+b.String() != expect {
t.Fatal("\n" + b.String())
}
})
t.Run("custom bullet longer", func(t *testing.T) {
doc := textfmt.Doc(
textfmt.DefinitionList(
textfmt.Definition(
textfmt.Text("one"),
textfmt.Text("foo bar baz"),
textfmt.Bullet("=>"),
),
textfmt.Definition(
textfmt.Text("two"),
textfmt.Text("qux"),
textfmt.Bullet("=>"),
),
textfmt.Definition(
textfmt.Text("three"),
textfmt.Text("quux"),
textfmt.Bullet("=>"),
),
),
)
var b bytes.Buffer
if err := textfmt.Runoff(&b, doc); err != nil {
t.Fatal(err)
}
const expect = `
.in 10
.ti 0
=> one:\~\~\~foo bar baz
.br
.in 10
.ti 0
=> two:\~\~\~qux
.br
.in 10
.ti 0
=> three:\~quux
`
if "\n"+b.String() != expect {
t.Fatal("\n" + b.String())
}
})
t.Run("no bullet", func(t *testing.T) {
doc := textfmt.Doc(
textfmt.DefinitionList(
textfmt.Definition(
textfmt.Text("one"),
textfmt.Text("foo bar baz"),
textfmt.NoBullet(),
),
textfmt.Definition(
textfmt.Text("two"),
textfmt.Text("qux"),
textfmt.NoBullet(),
),
textfmt.Definition(
textfmt.Text("three"),
textfmt.Text("quux"),
textfmt.NoBullet(),
),
),
)
var b bytes.Buffer
if err := textfmt.Runoff(&b, doc); err != nil {
t.Fatal(err)
}
const expect = `
.in 7
.ti 0
one:\~\~\~foo bar baz
.br
.in 7
.ti 0
two:\~\~\~qux
.br
.in 7
.ti 0
three:\~quux
`
if "\n"+b.String() != expect {

View File

@ -83,7 +83,19 @@ func renderTTYList(w io.Writer, e Entry) {
write(w, "\n")
}
p := itemToParagraph(e, item.text, "-")
var p Entry
itemStyle := mergeItemStyles(item.style)
if itemStyle.noBullet {
p = itemToParagraph(e, item.text)
} else {
bullet := "-"
if itemStyle.bullet != "" {
bullet = itemStyle.bullet
}
p = itemToParagraph(e, item.text, bullet)
}
renderTTYParagraph(w, p)
}
}
@ -108,10 +120,23 @@ func renderTTYDefinitions(w io.Writer, e Entry) {
write(w, "\n")
}
var bullet string
padLength := 1
itemStyle := mergeItemStyles(definition.style)
if !itemStyle.noBullet {
if itemStyle.bullet == "" {
bullet = "- "
padLength = 3
} else {
bullet = itemStyle.bullet + " "
padLength = len([]rune(bullet)) + 1
}
}
p := itemToParagraph(
e,
definition.value,
padRight(fmt.Sprintf("- %s:", names[i]), maxNameLength+3),
padRight(fmt.Sprintf("%s%s:", bullet, names[i]), maxNameLength+padLength),
)
renderTTYParagraph(w, p)

View File

@ -643,6 +643,93 @@ third item
t.Fatal(b.String())
}
})
t.Run("custom bullet", func(t *testing.T) {
doc := textfmt.Doc(
textfmt.Wrap(
textfmt.List(
textfmt.Item(textfmt.Text("foo bar baz"), textfmt.Bullet("*")),
textfmt.Item(textfmt.Text("qux"), textfmt.Bullet("*")),
textfmt.Item(textfmt.Text("quux"), textfmt.Bullet("*")),
),
10,
),
)
var b bytes.Buffer
if err := textfmt.Teletype(&b, doc); err != nil {
t.Fatal(err)
}
const expect = `
* foo bar
baz
* qux
* quux
`
if "\n"+b.String() != expect {
t.Fatal("\n" + b.String())
}
})
t.Run("custom bullet longer", func(t *testing.T) {
doc := textfmt.Doc(
textfmt.Wrap(
textfmt.List(
textfmt.Item(textfmt.Text("foo bar baz"), textfmt.Bullet("=>")),
textfmt.Item(textfmt.Text("qux"), textfmt.Bullet("=>")),
textfmt.Item(textfmt.Text("quux"), textfmt.Bullet("=>")),
),
10,
),
)
var b bytes.Buffer
if err := textfmt.Teletype(&b, doc); err != nil {
t.Fatal(err)
}
const expect = `
=> foo bar
baz
=> qux
=> quux
`
if "\n"+b.String() != expect {
t.Fatal("\n" + b.String())
}
})
t.Run("no bullet", func(t *testing.T) {
doc := textfmt.Doc(
textfmt.Wrap(
textfmt.List(
textfmt.Item(textfmt.Text("foo bar baz"), textfmt.NoBullet()),
textfmt.Item(textfmt.Text("qux"), textfmt.NoBullet()),
textfmt.Item(textfmt.Text("quux"), textfmt.NoBullet()),
),
10,
),
)
var b bytes.Buffer
if err := textfmt.Teletype(&b, doc); err != nil {
t.Fatal(err)
}
const expect = `
foo bar
baz
qux
quux
`
if "\n"+b.String() != expect {
t.Fatal("\n" + b.String())
}
})
})
t.Run("numbered list", func(t *testing.T) {
@ -989,6 +1076,129 @@ third item
t.Fatal("\n" + b.String())
}
})
t.Run("custom bullet", func(t *testing.T) {
doc := textfmt.Doc(
textfmt.Wrap(
textfmt.DefinitionList(
textfmt.Definition(
textfmt.Text("one"),
textfmt.Text("foo bar baz"),
textfmt.Bullet("*"),
),
textfmt.Definition(
textfmt.Text("two"),
textfmt.Text("qux"),
textfmt.Bullet("*"),
),
textfmt.Definition(
textfmt.Text("three"),
textfmt.Text("quux"),
textfmt.Bullet("*"),
),
),
18,
),
)
var b bytes.Buffer
if err := textfmt.Teletype(&b, doc); err != nil {
t.Fatal(err)
}
const expect = `
* one: foo bar
baz
* two: qux
* three: quux
`
if "\n"+b.String() != expect {
t.Fatal("\n" + b.String())
}
})
t.Run("custom bullet longer", func(t *testing.T) {
doc := textfmt.Doc(
textfmt.Wrap(
textfmt.DefinitionList(
textfmt.Definition(
textfmt.Text("one"),
textfmt.Text("foo bar baz"),
textfmt.Bullet("=>"),
),
textfmt.Definition(
textfmt.Text("two"),
textfmt.Text("qux"),
textfmt.Bullet("=>"),
),
textfmt.Definition(
textfmt.Text("three"),
textfmt.Text("quux"),
textfmt.Bullet("=>"),
),
),
18,
),
)
var b bytes.Buffer
if err := textfmt.Teletype(&b, doc); err != nil {
t.Fatal(err)
}
const expect = `
=> one: foo bar
baz
=> two: qux
=> three: quux
`
if "\n"+b.String() != expect {
t.Fatal("\n" + b.String())
}
})
t.Run("no bullet", func(t *testing.T) {
doc := textfmt.Doc(
textfmt.Wrap(
textfmt.DefinitionList(
textfmt.Definition(
textfmt.Text("one"),
textfmt.Text("foo bar baz"),
textfmt.NoBullet(),
),
textfmt.Definition(
textfmt.Text("two"),
textfmt.Text("qux"),
textfmt.NoBullet(),
),
textfmt.Definition(
textfmt.Text("three"),
textfmt.Text("quux"),
textfmt.NoBullet(),
),
),
15,
),
)
var b bytes.Buffer
if err := textfmt.Teletype(&b, doc); err != nil {
t.Fatal(err)
}
const expect = `
one: foo bar
baz
two: qux
three: quux
`
if "\n"+b.String() != expect {
t.Fatal("\n" + b.String())
}
})
})
t.Run("numbered definition list", func(t *testing.T) {

10
text.go
View File

@ -73,3 +73,13 @@ func itemToParagraph(list Entry, itemText Txt, prefix ...string) Entry {
p.text.cat = append(prefixText, itemText)
return p
}
func mergeItemStyles(s []ItemStyle) ItemStyle {
var m ItemStyle
for _, si := range s {
m.bullet = si.bullet
m.noBullet = m.noBullet && si.bullet == "" || si.noBullet
}
return m
}