Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 7 additions & 25 deletions internal/ui/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"github.com/localstack/lstk/internal/output"
"github.com/localstack/lstk/internal/ui/components"
"github.com/localstack/lstk/internal/ui/styles"
"github.com/localstack/lstk/internal/ui/wrap"
)

const maxLines = 200
Expand Down Expand Up @@ -251,24 +252,6 @@ func formatResolvedInput(req output.UserInputRequestEvent, selectedKey string) s

const lineIndent = 2

func hardWrap(s string, maxWidth int) string {
rs := []rune(s)
if maxWidth <= 0 || len(rs) <= maxWidth {
return s
}
var sb strings.Builder
for i := 0; i < len(rs); i += maxWidth {
if i > 0 {
sb.WriteByte('\n')
}
end := i + maxWidth
if end > len(rs) {
end = len(rs)
}
sb.WriteString(string(rs[i:end]))
}
return sb.String()
}

func isURL(s string) bool {
return strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://")
Expand All @@ -290,7 +273,7 @@ func (a App) View() string {
for _, line := range a.lines {
if line.highlight {
if isURL(line.text) {
wrapped := strings.Split(hardWrap(line.text, contentWidth), "\n")
wrapped := strings.Split(wrap.HardWrap(line.text, contentWidth), "\n")
var styledParts []string
for _, part := range wrapped {
styledParts = append(styledParts, styles.Link.Render(part))
Expand All @@ -299,22 +282,22 @@ func (a App) View() string {
sb.WriteString(hyperlink(line.text, strings.Join(styledParts, "\n"+indent)))
} else {
sb.WriteString(indent)
sb.WriteString(styles.Highlight.Render(hardWrap(line.text, contentWidth)))
sb.WriteString(styles.Highlight.Render(wrap.HardWrap(line.text, contentWidth)))
}
sb.WriteString("\n\n")
continue
} else if line.secondary {
if strings.HasPrefix(line.text, ">") {
sb.WriteString(styles.SecondaryMessage.Render(hardWrap(line.text, contentWidth)))
sb.WriteString(styles.SecondaryMessage.Render(wrap.HardWrap(line.text, contentWidth)))
sb.WriteString("\n\n")
continue
}
sb.WriteString(indent)
text := hardWrap(line.text, contentWidth)
text := wrap.HardWrap(line.text, contentWidth)
sb.WriteString(styles.SecondaryMessage.Render(text))
} else {
sb.WriteString(indent)
text := hardWrap(line.text, contentWidth)
text := wrap.HardWrap(line.text, contentWidth)
sb.WriteString(text)
}
sb.WriteString("\n")
Expand All @@ -328,8 +311,7 @@ func (a App) View() string {
sb.WriteString("\n")
}

if errorView := a.errorDisplay.View(); errorView != "" {
sb.WriteString("\n")
if errorView := a.errorDisplay.View(contentWidth); errorView != "" {
sb.WriteString(errorView)
}

Expand Down
10 changes: 0 additions & 10 deletions internal/ui/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -272,16 +272,6 @@ func TestAppEnterDoesNothingWithoutExplicitEnterOption(t *testing.T) {
}
}

func TestHardWrapHandlesUTF8Runes(t *testing.T) {
t.Parallel()

got := hardWrap("A🙂BC", 2)
want := "A🙂\nBC"
if got != want {
t.Fatalf("expected %q, got %q", want, got)
}
}

func TestAppAnyKeyOptionResolvesOnAnyKeypress(t *testing.T) {
t.Parallel()

Expand Down
29 changes: 22 additions & 7 deletions internal/ui/components/error_display.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (

"github.com/localstack/lstk/internal/output"
"github.com/localstack/lstk/internal/ui/styles"
"github.com/localstack/lstk/internal/ui/wrap"
)

type ErrorDisplay struct {
Expand All @@ -26,7 +27,7 @@ func (e ErrorDisplay) Visible() bool {
return e.visible
}

func (e ErrorDisplay) View() string {
func (e ErrorDisplay) View(maxWidth int) string {
if !e.visible || e.event == nil {
return ""
}
Expand All @@ -37,9 +38,18 @@ func (e ErrorDisplay) View() string {
sb.WriteString("\n")

if e.event.Summary != "" {
sb.WriteString(styles.Secondary.Render("> "))
sb.WriteString(styles.Message.Render(e.event.Summary))
sb.WriteString("\n")
prefix := "> "
summaryWidth := maxWidth - len(prefix)
lines := wrap.SoftWrap(e.event.Summary, summaryWidth)
for i, line := range lines {
if i == 0 {
sb.WriteString(styles.Secondary.Render(prefix))
} else {
sb.WriteString(strings.Repeat(" ", len(prefix)))
}
sb.WriteString(styles.Message.Render(line))
sb.WriteString("\n")
}
}

if e.event.Detail != "" {
Expand All @@ -50,12 +60,17 @@ func (e ErrorDisplay) View() string {

if len(e.event.Actions) > 0 {
sb.WriteString("\n")
for _, action := range e.event.Actions {
sb.WriteString(styles.ErrorAction.Render("⇒ " + action.Label + " "))
sb.WriteString(styles.Message.Bold(true).Render(action.Value))
for i, action := range e.event.Actions {
if i > 0 {
sb.WriteString(styles.SecondaryMessage.Render("⇒ " + action.Label + " " + action.Value))
} else {
sb.WriteString(styles.ErrorAction.Render("⇒ " + action.Label + " "))
sb.WriteString(styles.Message.Bold(true).Render(action.Value))
}
sb.WriteString("\n")
}
}

return sb.String()
}

6 changes: 3 additions & 3 deletions internal/ui/components/error_display_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ func TestErrorDisplay_ShowView(t *testing.T) {
t.Fatal("expected error display to be hidden initially")
}

if e.View() != "" {
if e.View(80) != "" {
t.Fatal("expected empty view when error display is hidden")
}

Expand All @@ -32,7 +32,7 @@ func TestErrorDisplay_ShowView(t *testing.T) {
t.Fatal("expected error display to be visible after Show")
}

view := e.View()
view := e.View(80)
if !strings.Contains(view, "Connection failed") {
t.Fatalf("expected view to contain title, got: %q", view)
}
Expand All @@ -56,7 +56,7 @@ func TestErrorDisplay_MinimalEvent(t *testing.T) {
e := NewErrorDisplay()
e = e.Show(output.ErrorEvent{Title: "Something went wrong"})

view := e.View()
view := e.View(80)
if !strings.Contains(view, "Something went wrong") {
t.Fatalf("expected view to contain title, got: %q", view)
}
Expand Down
90 changes: 90 additions & 0 deletions internal/ui/wrap/wrap.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package wrap

import (
"strings"
"unicode/utf8"
)

// HardWrap inserts newlines so that no output line exceeds maxWidth runes.
// It breaks at exact rune boundaries with no regard for word boundaries.
func HardWrap(s string, maxWidth int) string {
rs := []rune(s)
if maxWidth <= 0 || len(rs) <= maxWidth {
return s
}
var sb strings.Builder
for i := 0; i < len(rs); i += maxWidth {
if i > 0 {
sb.WriteByte('\n')
}
end := i + maxWidth
if end > len(rs) {
end = len(rs)
}
sb.WriteString(string(rs[i:end]))
}
return sb.String()
}

// SoftWrap breaks text into lines at word boundaries, falling back to hard
// breaks for words longer than maxWidth.
func SoftWrap(text string, maxWidth int) []string {
if maxWidth <= 0 || utf8.RuneCountInString(text) <= maxWidth {
return []string{text}
}

words := strings.Fields(text)
if len(words) == 0 {
return []string{text}
}

var lines []string
var current strings.Builder
currentRunes := 0

for _, word := range words {
runes := []rune(word)
wordRunes := len(runes)
if wordRunes > maxWidth {
if currentRunes > 0 {
lines = append(lines, current.String())
current.Reset()
currentRunes = 0
}
for len(runes) > 0 {
chunk := runes
if len(chunk) > maxWidth {
chunk = runes[:maxWidth]
}
runes = runes[len(chunk):]
if len(runes) > 0 || len(chunk) == maxWidth {
lines = append(lines, string(chunk))
} else {
current.WriteString(string(chunk))
currentRunes = len(chunk)
}
}
continue
}
if currentRunes == 0 {
current.WriteString(word)
currentRunes = wordRunes
continue
}
if currentRunes+1+wordRunes > maxWidth {
lines = append(lines, current.String())
current.Reset()
current.WriteString(word)
currentRunes = wordRunes
} else {
current.WriteByte(' ')
current.WriteString(word)
currentRunes += 1 + wordRunes
}
}
if currentRunes > 0 {
lines = append(lines, current.String())
}

return lines
}
Loading