diff --git a/internal/formatter/table.go b/internal/formatter/table.go index 64f36a8..e8186e3 100644 --- a/internal/formatter/table.go +++ b/internal/formatter/table.go @@ -4,6 +4,7 @@ import ( "fmt" "io" "strings" + "unicode/utf8" "royalroadupdates/internal/royalroad" ) @@ -17,17 +18,46 @@ func PrintTable(w io.Writer, stories []royalroad.Story) { nameHeader := "Story Name" updateHeader := "Last Update" - maxNameLen := len(nameHeader) + // 1. Measure natural widths in runes + maxNameLen := utf8.RuneCountInString(nameHeader) + maxUpdateLen := utf8.RuneCountInString(updateHeader) + for _, s := range stories { - if l := len(s.Name); l > maxNameLen { + if l := utf8.RuneCountInString(s.Name); l > maxNameLen { maxNameLen = l } + if l := utf8.RuneCountInString(Relative(s.DateModified)); l > maxUpdateLen { + maxUpdateLen = l + } } - fmt.Fprintf(w, "%-*s %s\n", maxNameLen, nameHeader, updateHeader) - fmt.Fprintf(w, "%s %s\n", strings.Repeat("-", maxNameLen), strings.Repeat("-", len(updateHeader))) + // 2. Determine final name column width based on terminal availability + nameWidth := maxNameLen + const minNameWidth = 30 + const gutter = 2 + + termWidth, err := getTerminalWidth() + if err == nil { + // We have a terminal. check if it's wide enough to support a cap above the min. + if termWidth >= minNameWidth+gutter+maxUpdateLen { + availableForName := termWidth - gutter - maxUpdateLen + if nameWidth > availableForName { + nameWidth = availableForName + } + } + } + + // 3. Render Table + fmt.Fprintf(w, "%-*s %s\n", nameWidth, nameHeader, updateHeader) + fmt.Fprintf(w, "%s %s\n", strings.Repeat("-", nameWidth), strings.Repeat("-", maxUpdateLen)) for _, s := range stories { - fmt.Fprintf(w, "%-*s %s\n", maxNameLen, s.Name, Relative(s.DateModified)) + displayName := s.Name + runeName := []rune(displayName) + if len(runeName) > nameWidth { + // Truncate to (nameWidth - 3) and add ellipsis + displayName = string(runeName[:nameWidth-3]) + "..." + } + fmt.Fprintf(w, "%-*s %s\n", nameWidth, displayName, Relative(s.DateModified)) } } diff --git a/internal/formatter/table_test.go b/internal/formatter/table_test.go new file mode 100644 index 0000000..562acf8 --- /dev/null +++ b/internal/formatter/table_test.go @@ -0,0 +1,58 @@ +package formatter + +import ( + "bytes" + "strings" + "testing" + "time" + + "royalroadupdates/internal/royalroad" +) + +func TestPrintTableTruncation(t *testing.T) { + stories := []royalroad.Story{ + { + Name: "Short Name", + DateModified: time.Now(), + }, + { + Name: "This is a very long story name that should definitely be truncated if the column width is restricted", + DateModified: time.Now(), + }, + { + Name: "Unicode Story 🌟 Sparkle", + DateModified: time.Now(), + }, + } + + t.Run("Natural width (no terminal)", func(t *testing.T) { + var buf bytes.Buffer + PrintTable(&buf, stories) + out := buf.String() + + expectedLongName := "This is a very long story name that should definitely be truncated if the column width is restricted" + if !strings.Contains(out, expectedLongName) { + t.Errorf("Expected output to contain full long name when not truncated, but it didn't. Output:\n%s", out) + } + }) + + t.Run("Check relative time alignment", func(t *testing.T) { + var buf bytes.Buffer + PrintTable(&buf, stories) + out := buf.String() + + lines := strings.Split(out, "\n") + if len(lines) < 2 { + t.Fatalf("Expected at least 2 lines of output, got %d", len(lines)) + } + separatorLine := lines[1] + parts := strings.Split(separatorLine, " ") + if len(parts) < 2 { + t.Fatalf("Separator line does not contain expected gutter: %s", separatorLine) + } + updateDashes := parts[1] + if len(updateDashes) < len("Last Update") { + t.Errorf("Update column separator too short: %d < 11", len(updateDashes)) + } + }) +} diff --git a/internal/formatter/terminal_linux.go b/internal/formatter/terminal_linux.go new file mode 100644 index 0000000..bdfa152 --- /dev/null +++ b/internal/formatter/terminal_linux.go @@ -0,0 +1,29 @@ +//go:build linux + +package formatter + +import ( + "syscall" + "unsafe" +) + +type winsize struct { + Row, Col uint16 + Xpixel, Ypixel uint16 +} + +// getTerminalWidth returns the width of the stdout terminal in columns. +// If the output is not a terminal or the width cannot be determined, it returns (0, error). +func getTerminalWidth() (int, error) { + var ws winsize + _, _, errno := syscall.Syscall( + syscall.SYS_IOCTL, + uintptr(syscall.Stdout), + uintptr(syscall.TIOCGWINSZ), + uintptr(unsafe.Pointer(&ws)), + ) + if errno != 0 { + return 0, errno + } + return int(ws.Col), nil +} diff --git a/internal/formatter/terminal_other.go b/internal/formatter/terminal_other.go new file mode 100644 index 0000000..bfb64a5 --- /dev/null +++ b/internal/formatter/terminal_other.go @@ -0,0 +1,11 @@ +//go:build !linux + +package formatter + +import "errors" + +// getTerminalWidth returns the width of the stdout terminal in columns. +// On non-linux platforms, this is not implemented and always returns an error. +func getTerminalWidth() (int, error) { + return 0, errors.New("not implemented on this platform") +}