jesseduffield.lazygit/pkg/utils/formatting.go
Stefan Haller 2135e928db Add a truncation function that puts the ellipsis in the middle
TruncateWithEllipsis cuts off the end of a string, which is the wrong
end for a path: what distinguishes two paths is often the last segment,
and it is the one thing the reader wants to see. Keep both ends and put
the ellipsis between them.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 19:01:55 +02:00

269 lines
7 KiB
Go

package utils
import (
"fmt"
"strings"
"unicode"
"github.com/rivo/uniseg"
"github.com/samber/lo"
"golang.org/x/exp/slices"
)
type Alignment int
const (
AlignLeft Alignment = iota
AlignRight
)
type ColumnConfig struct {
Width int
Alignment Alignment
}
func StringWidth(s string) int {
// We are intentionally not using a range loop here, because that would
// convert the characters to runes, which is unnecessary work in this case.
for i := range len(s) {
if s[i] > unicode.MaxASCII {
return uniseg.StringWidth(s)
}
}
return len(s)
}
// WithPadding pads a string as much as you want
func WithPadding(str string, padding int, alignment Alignment) string {
uncoloredStr := Decolorise(str)
width := StringWidth(uncoloredStr)
if padding < width {
return str
}
space := strings.Repeat(" ", padding-width)
if alignment == AlignLeft {
return str + space
}
return space + str
}
// defaults to left-aligning each column. If you want to set the alignment of
// each column, pass in a slice of Alignment values.
// returns a list of strings that should be joined with "\n", and an array of
// the column positions
func RenderDisplayStrings(displayStringsArr [][]string, columnAlignments []Alignment) ([]string, []int) {
if len(displayStringsArr) == 0 {
return []string{}, nil
}
displayStringsArr, columnAlignments, removedColumns := excludeBlankColumns(displayStringsArr, columnAlignments)
padWidths := getPadWidths(displayStringsArr)
columnConfigs := make([]ColumnConfig, len(padWidths))
columnPositions := make([]int, len(padWidths)+1)
columnPositions[0] = 0
for i, padWidth := range padWidths {
// gracefully handle when columnAlignments is shorter than padWidths
alignment := AlignLeft
if len(columnAlignments) > i {
alignment = columnAlignments[i]
}
columnConfigs[i] = ColumnConfig{
Width: padWidth,
Alignment: alignment,
}
columnPositions[i+1] = columnPositions[i] + padWidth + 1
}
// Add the removed columns back into columnPositions (a removed column gets
// the same position as the following column); clients should be able to rely
// on them all to be there
for _, removedColumn := range removedColumns {
if removedColumn < len(columnPositions) {
columnPositions = slices.Insert(columnPositions, removedColumn, columnPositions[removedColumn])
}
}
return getPaddedDisplayStrings(displayStringsArr, columnConfigs), columnPositions
}
// NOTE: this mutates the input slice for the sake of performance
func excludeBlankColumns(displayStringsArr [][]string, columnAlignments []Alignment) ([][]string, []Alignment, []int) {
if len(displayStringsArr) == 0 {
return displayStringsArr, columnAlignments, []int{}
}
// if all rows share a blank column, we want to remove that column
toRemove := []int{}
outer:
for i := range displayStringsArr[0] {
for _, strings := range displayStringsArr {
if strings[i] != "" {
continue outer
}
}
toRemove = append(toRemove, i)
}
if len(toRemove) == 0 {
return displayStringsArr, columnAlignments, []int{}
}
// remove the columns
for i, strings := range displayStringsArr {
for j := len(toRemove) - 1; j >= 0; j-- {
strings = slices.Delete(strings, toRemove[j], toRemove[j]+1)
}
displayStringsArr[i] = strings
}
for j := len(toRemove) - 1; j >= 0; j-- {
if columnAlignments != nil && toRemove[j] < len(columnAlignments) {
columnAlignments = slices.Delete(columnAlignments, toRemove[j], toRemove[j]+1)
}
}
return displayStringsArr, columnAlignments, toRemove
}
func getPaddedDisplayStrings(stringArrays [][]string, columnConfigs []ColumnConfig) []string {
result := make([]string, 0, len(stringArrays))
for _, stringArray := range stringArrays {
if len(stringArray) == 0 {
continue
}
builder := strings.Builder{}
for j, columnConfig := range columnConfigs {
if len(stringArray)-1 < j {
continue
}
builder.WriteString(WithPadding(stringArray[j], columnConfig.Width, columnConfig.Alignment))
builder.WriteString(" ")
}
if len(stringArray)-1 < len(columnConfigs) {
continue
}
builder.WriteString(stringArray[len(columnConfigs)])
result = append(result, builder.String())
}
return result
}
func getPadWidths(stringArrays [][]string) []int {
maxWidth := MaxFn(stringArrays, func(stringArray []string) int {
return len(stringArray)
})
if maxWidth-1 < 0 {
return []int{}
}
return lo.Map(lo.Range(maxWidth-1), func(i int, _ int) int {
return MaxFn(stringArrays, func(stringArray []string) int {
uncoloredStr := Decolorise(stringArray[i])
return StringWidth(uncoloredStr)
})
})
}
func MaxFn[T any](items []T, fn func(T) int) int {
max := 0
for _, item := range items {
if fn(item) > max {
max = fn(item)
}
}
return max
}
// TruncateWithEllipsis returns a string, truncated to a certain length, with an ellipsis
func TruncateWithEllipsis(str string, limit int) string {
if StringWidth(str) <= limit {
return str
}
if limit <= 2 {
return strings.Repeat(".", limit)
}
state := -1
var grapheme string
var width int
truncatedStr := ""
for str != "" {
grapheme, str, width, state = uniseg.FirstGraphemeClusterInString(str, state)
if uniseg.StringWidth(truncatedStr)+width > limit-1 {
break
}
truncatedStr += grapheme
}
return truncatedStr + "…"
}
// TruncateWithEllipsisInMiddle returns a string, truncated to a certain width,
// with an ellipsis in the middle. Use it where the end of the string is as
// informative as its beginning, e.g. for paths.
func TruncateWithEllipsisInMiddle(str string, limit int) string {
if StringWidth(str) <= limit {
return str
}
if limit <= 2 {
return strings.Repeat(".", limit)
}
clusters := []string{}
widths := []int{}
graphemes := uniseg.NewGraphemes(str)
for graphemes.Next() {
clusters = append(clusters, graphemes.Str())
widths = append(widths, graphemes.Width())
}
// One column goes to the ellipsis; the rest is split between the two ends,
// with the odd one going to the front.
remaining := limit - 1
frontLimit := (remaining + 1) / 2
front := 0
frontWidth := 0
for front < len(clusters) && frontWidth+widths[front] <= frontLimit {
frontWidth += widths[front]
front++
}
// Whatever the front didn't use, e.g. because a wide grapheme didn't fit
// into it, is available to the back.
back := len(clusters)
backWidth := 0
for back > front && backWidth+widths[back-1] <= remaining-frontWidth {
backWidth += widths[back-1]
back--
}
return strings.Join(clusters[:front], "") + "…" + strings.Join(clusters[back:], "")
}
func SafeTruncate(str string, limit int) string {
if len(str) > limit {
return str[0:limit]
}
return str
}
const COMMIT_HASH_SHORT_SIZE = 8
func ShortHash(hash string) string {
if len(hash) < COMMIT_HASH_SHORT_SIZE {
return hash
}
return hash[:COMMIT_HASH_SHORT_SIZE]
}
// Returns comma-separated list of paths, with ellipsis if there are more than 3
// e.g. "foo, bar, baz, [...3 more]"
func FormatPaths(paths []string) string {
if len(paths) <= 3 {
return strings.Join(paths, ", ")
}
return fmt.Sprintf("%s, %s, %s, [...%d more]", paths[0], paths[1], paths[2], len(paths)-3)
}