Skip to content

Commit 5d46990

Browse files
authored
Merge pull request #8 from Daniel-Ric/feature/2026-02-03/improve-cli-usability-and-aesthetics
CLI: Verbesserte Eingabe-UI, dezente Farben, Spinner und Page-Rendering
2 parents ec0eb47 + a2e0c91 commit 5d46990

2 files changed

Lines changed: 144 additions & 19 deletions

File tree

internal/cli/cli.go

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -75,13 +75,14 @@ func (a *App) askEdition() (ping.Edition, error) {
7575
}
7676

7777
func (a *App) askHost() (string, error) {
78+
var errMsg string
7879
for {
79-
value, err := promptInput("Server Host")
80+
value, err := promptInput("Server Host", "z.B. play.example.com", errMsg)
8081
if err != nil {
8182
return "", err
8283
}
8384
if strings.TrimSpace(value) == "" {
84-
fmt.Println("Host darf nicht leer sein")
85+
errMsg = "Host darf nicht leer sein"
8586
continue
8687
}
8788
return value, nil
@@ -90,8 +91,9 @@ func (a *App) askHost() (string, error) {
9091

9192
func (a *App) askPort(edition ping.Edition) (int, error) {
9293
defaultPort := ping.DefaultPort(edition)
94+
var errMsg string
9395
for {
94-
value, err := promptInput(fmt.Sprintf("Port (%d)", defaultPort))
96+
value, err := promptInput(fmt.Sprintf("Port (%d)", defaultPort), "Leer lassen für Standardport", errMsg)
9597
if err != nil {
9698
return 0, err
9799
}
@@ -100,11 +102,11 @@ func (a *App) askPort(edition ping.Edition) (int, error) {
100102
}
101103
port, err := ping.ParsePort(value)
102104
if err != nil {
103-
fmt.Println(err)
105+
errMsg = err.Error()
104106
continue
105107
}
106108
if port == 0 {
107-
fmt.Println("Port darf nicht leer sein")
109+
errMsg = "Port darf nicht leer sein"
108110
continue
109111
}
110112
return port, nil
@@ -115,14 +117,18 @@ func (a *App) execute(config Config) error {
115117
ctx, cancel := context.WithTimeout(context.Background(), a.inputTimeout)
116118
defer cancel()
117119

118-
result, err := ping.Execute(ctx, config.Edition, config.Host, config.Port)
120+
resultText, err := withSpinner("Abfrage", "Server wird abgefragt", 120*time.Millisecond, func() (string, error) {
121+
result, err := ping.Execute(ctx, config.Edition, config.Host, config.Port)
122+
if err != nil {
123+
return "", err
124+
}
125+
return result.String(), nil
126+
})
119127
if err != nil {
120128
return err
121129
}
122130

123-
fmt.Println()
124-
fmt.Println(result.String())
125-
fmt.Println()
131+
renderTextPage("Ergebnis", resultText)
126132
return nil
127133
}
128134

internal/cli/ui.go

Lines changed: 129 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,44 @@ import (
66
"fmt"
77
"os"
88
"strings"
9+
"time"
910
)
1011

11-
func promptInput(label string) (string, error) {
12+
const (
13+
colorReset = "\033[0m"
14+
colorDim = "\033[2m"
15+
colorAccent = "\033[36m"
16+
colorWarn = "\033[33m"
17+
colorBold = "\033[1m"
18+
)
19+
20+
func supportsColor() bool {
21+
if os.Getenv("NO_COLOR") != "" {
22+
return false
23+
}
24+
term := strings.TrimSpace(os.Getenv("TERM"))
25+
return term != "" && term != "dumb"
26+
}
27+
28+
func style(text, color string) string {
29+
if !supportsColor() || color == "" {
30+
return text
31+
}
32+
return color + text + colorReset
33+
}
34+
35+
func promptInput(label, hint, errMsg string) (string, error) {
36+
clearScreen()
37+
fmt.Println(style(label, colorAccent))
38+
if hint != "" {
39+
fmt.Println(style(hint, colorDim))
40+
}
41+
if errMsg != "" {
42+
fmt.Println(style(errMsg, colorWarn))
43+
}
44+
fmt.Println()
45+
fmt.Print(style("> ", colorAccent))
1246
reader := bufio.NewReader(os.Stdin)
13-
fmt.Printf("%s: ", label)
1447
value, err := reader.ReadString('\n')
1548
if err != nil {
1649
return "", err
@@ -19,6 +52,9 @@ func promptInput(label string) (string, error) {
1952
}
2053

2154
func selectOption(title string, options []string) (int, error) {
55+
if len(options) == 0 {
56+
return 0, errors.New("keine Auswahloptionen verfügbar")
57+
}
2258
fd := int(os.Stdin.Fd())
2359
state, err := makeRaw(fd)
2460
if err != nil {
@@ -27,7 +63,7 @@ func selectOption(title string, options []string) (int, error) {
2763
defer restore(fd, state)
2864

2965
selected := 0
30-
renderMenu(title, options, selected)
66+
renderMenu(title, options, selected, "")
3167

3268
reader := bufio.NewReader(os.Stdin)
3369
for {
@@ -39,6 +75,16 @@ func selectOption(title string, options []string) (int, error) {
3975
switch b {
4076
case 3:
4177
return 0, errors.New("abgebrochen")
78+
case 'w', 'k':
79+
if selected > 0 {
80+
selected--
81+
}
82+
renderMenu(title, options, selected, "")
83+
case 's', 'j':
84+
if selected < len(options)-1 {
85+
selected++
86+
}
87+
renderMenu(title, options, selected, "")
4288
case 13, 10:
4389
clearScreen()
4490
return selected, nil
@@ -47,17 +93,29 @@ func selectOption(title string, options []string) (int, error) {
4793
if err != nil {
4894
return 0, err
4995
}
50-
if seq == "[A" {
96+
if seq == "[A" || seq == "OA" {
5197
if selected > 0 {
5298
selected--
5399
}
54100
}
55-
if seq == "[B" {
101+
if seq == "[B" || seq == "OB" {
56102
if selected < len(options)-1 {
57103
selected++
58104
}
59105
}
60-
renderMenu(title, options, selected)
106+
renderMenu(title, options, selected, "")
107+
case 0, 224:
108+
code, err := reader.ReadByte()
109+
if err != nil {
110+
return 0, err
111+
}
112+
if code == 72 && selected > 0 {
113+
selected--
114+
}
115+
if code == 80 && selected < len(options)-1 {
116+
selected++
117+
}
118+
renderMenu(title, options, selected, "")
61119
}
62120
}
63121
}
@@ -71,24 +129,85 @@ func readEscapeSequence(reader *bufio.Reader) (string, error) {
71129
if err != nil {
72130
return "", err
73131
}
132+
if b1 == '[' && b2 >= '0' && b2 <= '9' {
133+
b3, err := reader.ReadByte()
134+
if err != nil {
135+
return "", err
136+
}
137+
return string([]byte{b1, b2, b3}), nil
138+
}
74139
return string([]byte{b1, b2}), nil
75140
}
76141

77-
func renderMenu(title string, options []string, selected int) {
142+
func renderMenu(title string, options []string, selected int, hint string) {
78143
clearScreen()
79-
fmt.Println(title)
144+
fmt.Println(style(title, colorAccent))
80145
fmt.Println()
81146
for i, option := range options {
82147
if i == selected {
83-
fmt.Printf("> %s\n", option)
148+
fmt.Printf("%s %s\n", style(">", colorAccent+colorBold), style(option, colorBold))
84149
continue
85150
}
86151
fmt.Printf(" %s\n", option)
87152
}
88153
fmt.Println()
89-
fmt.Println("Pfeiltasten zum Navigieren, Enter zum Bestätigen")
154+
if hint == "" {
155+
hint = "Pfeiltasten (oder W/S), Enter zum Bestätigen"
156+
}
157+
fmt.Println(style(hint, colorDim))
90158
}
91159

92160
func clearScreen() {
93161
fmt.Print("\033[H\033[2J")
94162
}
163+
164+
func renderTextPage(title, content string) {
165+
lines := strings.Split(content, "\n")
166+
renderPage(title, lines)
167+
}
168+
169+
func renderPage(title string, lines []string) {
170+
clearScreen()
171+
fmt.Println(style(title, colorAccent))
172+
fmt.Println()
173+
for _, line := range lines {
174+
if strings.TrimSpace(line) == "" {
175+
fmt.Println()
176+
continue
177+
}
178+
fmt.Println(line)
179+
}
180+
}
181+
182+
func renderSpinnerPage(title, message, frame string) {
183+
renderPage(title, []string{fmt.Sprintf("%s %s", style(message, colorDim), style(frame, colorAccent))})
184+
}
185+
186+
func withSpinner(title, message string, tick time.Duration, action func() (string, error)) (string, error) {
187+
resultCh := make(chan struct {
188+
result string
189+
err error
190+
}, 1)
191+
go func() {
192+
result, err := action()
193+
resultCh <- struct {
194+
result string
195+
err error
196+
}{result: result, err: err}
197+
}()
198+
199+
frames := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
200+
ticker := time.NewTicker(tick)
201+
defer ticker.Stop()
202+
203+
frame := 0
204+
for {
205+
select {
206+
case res := <-resultCh:
207+
return res.result, res.err
208+
case <-ticker.C:
209+
renderSpinnerPage(title, message, frames[frame])
210+
frame = (frame + 1) % len(frames)
211+
}
212+
}
213+
}

0 commit comments

Comments
 (0)