github.com/liamawhite/cli-with-i18n@v6.32.1-0.20171122084555-dede0a5c3448+incompatible/util/ui/ui.go (about) 1 // Package ui will provide hooks into STDOUT, STDERR and STDIN. It will also 2 // handle translation as necessary. 3 // 4 // This package is explicitly designed for the CF CLI and is *not* to be used 5 // by any package outside of the commands package. 6 package ui 7 8 import ( 9 "fmt" 10 "io" 11 "os" 12 "strings" 13 "sync" 14 "time" 15 16 "github.com/fatih/color" 17 "github.com/liamawhite/cli-with-i18n/command/translatableerror" 18 "github.com/liamawhite/cli-with-i18n/util/configv3" 19 "github.com/lunixbochs/vtclean" 20 runewidth "github.com/mattn/go-runewidth" 21 "github.com/vito/go-interact/interact" 22 ) 23 24 // LogTimestampFormat is the timestamp formatting for log lines. 25 const LogTimestampFormat = "2006-01-02T15:04:05.00-0700" 26 27 // DefaultTableSpacePadding is the default space padding in tables. 28 const DefaultTableSpacePadding = 3 29 30 //go:generate counterfeiter . Config 31 32 // Config is the UI configuration. 33 type Config interface { 34 // ColorEnabled enables or disabled color 35 ColorEnabled() configv3.ColorSetting 36 // Locale is the language to translate the output to 37 Locale() string 38 // IsTTY returns true when the ui has a TTY 39 IsTTY() bool 40 // TerminalWidth returns the width of the terminal 41 TerminalWidth() int 42 } 43 44 //go:generate counterfeiter . LogMessage 45 46 // LogMessage is a log response representing one to many joined lines of a log 47 // message. 48 type LogMessage interface { 49 Message() string 50 Type() string 51 Timestamp() time.Time 52 SourceType() string 53 SourceInstance() string 54 } 55 56 // UI is interface to interact with the user 57 type UI struct { 58 // In is the input buffer 59 In io.Reader 60 // Out is the output buffer 61 Out io.Writer 62 // Err is the error buffer 63 Err io.Writer 64 65 colorEnabled configv3.ColorSetting 66 translate TranslateFunc 67 68 terminalLock *sync.Mutex 69 fileLock *sync.Mutex 70 71 IsTTY bool 72 TerminalWidth int 73 74 TimezoneLocation *time.Location 75 } 76 77 // NewUI will return a UI object where Out is set to STDOUT, In is set to 78 // STDIN, and Err is set to STDERR 79 func NewUI(config Config) (*UI, error) { 80 translateFunc, err := GetTranslationFunc(config) 81 if err != nil { 82 return nil, err 83 } 84 85 location := time.Now().Location() 86 87 return &UI{ 88 In: os.Stdin, 89 Out: color.Output, 90 Err: os.Stderr, 91 colorEnabled: config.ColorEnabled(), 92 translate: translateFunc, 93 terminalLock: &sync.Mutex{}, 94 fileLock: &sync.Mutex{}, 95 IsTTY: config.IsTTY(), 96 TerminalWidth: config.TerminalWidth(), 97 TimezoneLocation: location, 98 }, nil 99 } 100 101 // NewTestUI will return a UI object where Out, In, and Err are customizable, 102 // and colors are disabled 103 func NewTestUI(in io.Reader, out io.Writer, err io.Writer) *UI { 104 translationFunc, translateErr := generateTranslationFunc([]byte("[]")) 105 if translateErr != nil { 106 panic(translateErr) 107 } 108 109 return &UI{ 110 In: in, 111 Out: out, 112 Err: err, 113 colorEnabled: configv3.ColorDisabled, 114 translate: translationFunc, 115 terminalLock: &sync.Mutex{}, 116 fileLock: &sync.Mutex{}, 117 TimezoneLocation: time.UTC, 118 } 119 } 120 121 // DisplayBoolPrompt outputs the prompt and waits for user input. It only 122 // allows for a boolean response. A default boolean response can be set with 123 // defaultResponse. 124 func (ui *UI) DisplayBoolPrompt(defaultResponse bool, template string, templateValues ...map[string]interface{}) (bool, error) { 125 ui.terminalLock.Lock() 126 defer ui.terminalLock.Unlock() 127 128 response := defaultResponse 129 interactivePrompt := interact.NewInteraction(ui.TranslateText(template, templateValues...)) 130 interactivePrompt.Input = ui.In 131 interactivePrompt.Output = ui.Out 132 err := interactivePrompt.Resolve(&response) 133 return response, err 134 } 135 136 // DisplayError outputs the translated error message to ui.Err if the error 137 // satisfies TranslatableError, otherwise it outputs the original error message 138 // to ui.Err. It also outputs "FAILED" in bold red to ui.Out. 139 func (ui *UI) DisplayError(err error) { 140 var errMsg string 141 if translatableError, ok := err.(translatableerror.TranslatableError); ok { 142 errMsg = translatableError.Translate(ui.translate) 143 } else { 144 errMsg = err.Error() 145 } 146 fmt.Fprintf(ui.Err, "%s\n", errMsg) 147 148 ui.terminalLock.Lock() 149 defer ui.terminalLock.Unlock() 150 151 fmt.Fprintf(ui.Out, "%s\n", ui.modifyColor(ui.TranslateText("FAILED"), color.New(color.FgRed, color.Bold))) 152 } 153 154 // DisplayHeader translates the header, bolds and adds the default color to the 155 // header, and outputs the result to ui.Out. 156 func (ui *UI) DisplayHeader(text string) { 157 ui.terminalLock.Lock() 158 defer ui.terminalLock.Unlock() 159 160 fmt.Fprintf(ui.Out, "%s\n", ui.modifyColor(ui.TranslateText(text), color.New(color.Bold))) 161 } 162 163 // DisplayKeyValueTable outputs a matrix of strings as a table to UI.Out. 164 // Prefix will be prepended to each row and padding adds the specified number 165 // of spaces between columns. The final columns may wrap to multiple lines but 166 // will still be confined to the last column. Wrapping will occur on word 167 // boundaries. 168 func (ui *UI) DisplayKeyValueTable(prefix string, table [][]string, padding int) { 169 rows := len(table) 170 if rows == 0 { 171 return 172 } 173 174 columns := len(table[0]) 175 176 if columns < 2 || !ui.IsTTY { 177 ui.DisplayNonWrappingTable(prefix, table, padding) 178 return 179 } 180 181 ui.displayWrappingTableWithWidth(prefix, table, padding) 182 } 183 184 // DisplayLogMessage formats and outputs a given log message. 185 func (ui *UI) DisplayLogMessage(message LogMessage, displayHeader bool) { 186 ui.terminalLock.Lock() 187 defer ui.terminalLock.Unlock() 188 189 var header string 190 if displayHeader { 191 time := message.Timestamp().In(ui.TimezoneLocation).Format(LogTimestampFormat) 192 193 header = fmt.Sprintf("%s [%s/%s] %s ", 194 time, 195 message.SourceType(), 196 message.SourceInstance(), 197 message.Type(), 198 ) 199 } 200 201 for _, line := range strings.Split(message.Message(), "\n") { 202 logLine := fmt.Sprintf("%s%s", header, strings.TrimRight(line, "\r\n")) 203 if message.Type() == "ERR" { 204 logLine = ui.modifyColor(logLine, color.New(color.FgRed)) 205 } 206 fmt.Fprintf(ui.Out, " %s\n", logLine) 207 } 208 } 209 210 // DisplayNewline outputs a newline to UI.Out. 211 func (ui *UI) DisplayNewline() { 212 ui.terminalLock.Lock() 213 defer ui.terminalLock.Unlock() 214 215 fmt.Fprintf(ui.Out, "\n") 216 } 217 218 // DisplayNonWrappingTable outputs a matrix of strings as a table to UI.Out. Prefix will 219 // be prepended to each row and padding adds the specified number of spaces 220 // between columns. 221 func (ui *UI) DisplayNonWrappingTable(prefix string, table [][]string, padding int) { 222 ui.terminalLock.Lock() 223 defer ui.terminalLock.Unlock() 224 225 if len(table) == 0 { 226 return 227 } 228 229 var columnPadding []int 230 231 rows := len(table) 232 columns := len(table[0]) 233 for col := 0; col < columns; col++ { 234 var max int 235 for row := 0; row < rows; row++ { 236 if strLen := wordSize(table[row][col]); max < strLen { 237 max = strLen 238 } 239 } 240 columnPadding = append(columnPadding, max+padding) 241 } 242 243 for row := 0; row < rows; row++ { 244 fmt.Fprintf(ui.Out, prefix) 245 for col := 0; col < columns; col++ { 246 data := table[row][col] 247 var addedPadding int 248 if col+1 != columns { 249 addedPadding = columnPadding[col] - wordSize(data) 250 } 251 fmt.Fprintf(ui.Out, "%s%s", data, strings.Repeat(" ", addedPadding)) 252 } 253 fmt.Fprintf(ui.Out, "\n") 254 } 255 } 256 257 // DisplayOK outputs a bold green translated "OK" to UI.Out. 258 func (ui *UI) DisplayOK() { 259 ui.terminalLock.Lock() 260 defer ui.terminalLock.Unlock() 261 262 fmt.Fprintf(ui.Out, "%s\n", ui.modifyColor(ui.TranslateText("OK"), color.New(color.FgGreen, color.Bold))) 263 } 264 265 func (ui *UI) DisplayTableWithHeader(prefix string, table [][]string, padding int) { 266 if len(table) == 0 { 267 return 268 } 269 for i, str := range table[0] { 270 table[0][i] = ui.modifyColor(str, color.New(color.Bold)) 271 } 272 273 ui.DisplayNonWrappingTable(prefix, table, padding) 274 } 275 276 // DisplayText translates the template, substitutes in templateValues, and 277 // outputs the result to ui.Out. Only the first map in templateValues is used. 278 func (ui *UI) DisplayText(template string, templateValues ...map[string]interface{}) { 279 ui.terminalLock.Lock() 280 defer ui.terminalLock.Unlock() 281 282 fmt.Fprintf(ui.Out, "%s\n", ui.TranslateText(template, templateValues...)) 283 } 284 285 // DisplayTextWithFlavor translates the template, bolds and adds cyan color to 286 // templateValues, substitutes templateValues into the template, and outputs 287 // the result to ui.Out. Only the first map in templateValues is used. 288 func (ui *UI) DisplayTextWithFlavor(template string, templateValues ...map[string]interface{}) { 289 ui.terminalLock.Lock() 290 defer ui.terminalLock.Unlock() 291 292 firstTemplateValues := getFirstSet(templateValues) 293 for key, value := range firstTemplateValues { 294 firstTemplateValues[key] = ui.modifyColor(fmt.Sprint(value), color.New(color.FgCyan, color.Bold)) 295 } 296 fmt.Fprintf(ui.Out, "%s\n", ui.TranslateText(template, firstTemplateValues)) 297 } 298 299 // DisplayTextWithBold translates the template, bolds the templateValues, 300 // substitutes templateValues into the template, and outputs 301 // the result to ui.Out. Only the first map in templateValues is used. 302 func (ui *UI) DisplayTextWithBold(template string, templateValues ...map[string]interface{}) { 303 ui.terminalLock.Lock() 304 defer ui.terminalLock.Unlock() 305 306 firstTemplateValues := getFirstSet(templateValues) 307 for key, value := range firstTemplateValues { 308 firstTemplateValues[key] = ui.modifyColor(fmt.Sprint(value), color.New(color.Bold)) 309 } 310 fmt.Fprintf(ui.Out, "%s\n", ui.TranslateText(template, firstTemplateValues)) 311 } 312 313 // DisplayWarning translates the warning, substitutes in templateValues, and 314 // outputs to ui.Err. Only the first map in templateValues is used. 315 func (ui *UI) DisplayWarning(template string, templateValues ...map[string]interface{}) { 316 fmt.Fprintf(ui.Err, "%s\n", ui.TranslateText(template, templateValues...)) 317 } 318 319 // DisplayWarnings translates the warnings and outputs to ui.Err. 320 func (ui *UI) DisplayWarnings(warnings []string) { 321 for _, warning := range warnings { 322 fmt.Fprintf(ui.Err, "%s\n", ui.TranslateText(warning)) 323 } 324 } 325 326 // RequestLoggerFileWriter returns a RequestLoggerFileWriter that cannot 327 // overwrite another RequestLoggerFileWriter. 328 func (ui *UI) RequestLoggerFileWriter(filePaths []string) *RequestLoggerFileWriter { 329 return newRequestLoggerFileWriter(ui, ui.fileLock, filePaths) 330 } 331 332 // RequestLoggerTerminalDisplay returns a RequestLoggerTerminalDisplay that 333 // cannot overwrite another RequestLoggerTerminalDisplay or the current 334 // display. 335 func (ui *UI) RequestLoggerTerminalDisplay() *RequestLoggerTerminalDisplay { 336 return newRequestLoggerTerminalDisplay(ui, ui.terminalLock) 337 } 338 339 // TranslateText passes the template through an internationalization function 340 // to translate it to a pre-configured language, and returns the template with 341 // templateValues substituted in. Only the first map in templateValues is used. 342 func (ui *UI) TranslateText(template string, templateValues ...map[string]interface{}) string { 343 return ui.translate(template, getFirstSet(templateValues)) 344 } 345 346 // UserFriendlyDate converts the time to UTC and then formats it to ISO8601. 347 func (ui *UI) UserFriendlyDate(input time.Time) string { 348 return input.Local().Format("Mon 02 Jan 15:04:05 MST 2006") 349 } 350 351 func (ui *UI) Writer() io.Writer { 352 return ui.Out 353 } 354 355 func (ui *UI) displayWrappingTableWithWidth(prefix string, table [][]string, padding int) { 356 ui.terminalLock.Lock() 357 defer ui.terminalLock.Unlock() 358 359 var columnPadding []int 360 361 rows := len(table) 362 columns := len(table[0]) 363 364 for col := 0; col < columns-1; col++ { 365 var max int 366 for row := 0; row < rows; row++ { 367 if strLen := runewidth.StringWidth(table[row][col]); max < strLen { 368 max = strLen 369 } 370 } 371 columnPadding = append(columnPadding, max+padding) 372 } 373 374 spilloverPadding := len(prefix) + sum(columnPadding) 375 lastColumnWidth := ui.TerminalWidth - spilloverPadding 376 377 for row := 0; row < rows; row++ { 378 fmt.Fprintf(ui.Out, prefix) 379 380 // for all columns except last, add cell value and padding 381 for col := 0; col < columns-1; col++ { 382 var addedPadding int 383 if col+1 != columns { 384 addedPadding = columnPadding[col] - runewidth.StringWidth(table[row][col]) 385 } 386 fmt.Fprintf(ui.Out, "%s%s", table[row][col], strings.Repeat(" ", addedPadding)) 387 } 388 389 // for last column, add each word individually. If the added word would make the column exceed terminal width, create a new line and add padding 390 words := strings.Split(table[row][columns-1], " ") 391 currentWidth := 0 392 393 for _, word := range words { 394 wordWidth := runewidth.StringWidth(word) 395 if currentWidth == 0 { 396 currentWidth = wordWidth 397 fmt.Fprintf(ui.Out, "%s", word) 398 } else if wordWidth+1+currentWidth > lastColumnWidth { 399 fmt.Fprintf(ui.Out, "\n%s%s", strings.Repeat(" ", spilloverPadding), word) 400 currentWidth = wordWidth 401 } else { 402 fmt.Fprintf(ui.Out, " %s", word) 403 currentWidth += wordWidth + 1 404 } 405 } 406 407 fmt.Fprintf(ui.Out, "\n") 408 } 409 } 410 411 // getFirstSet returns the first map if 1 or more maps are provided. Otherwise 412 // it returns the empty map. 413 func getFirstSet(list []map[string]interface{}) map[string]interface{} { 414 if list == nil || len(list) == 0 { 415 return map[string]interface{}{} 416 } 417 return list[0] 418 } 419 420 func (ui *UI) modifyColor(text string, colorPrinter *color.Color) string { 421 if len(text) == 0 { 422 return text 423 } 424 425 switch ui.colorEnabled { 426 case configv3.ColorEnabled: 427 colorPrinter.EnableColor() 428 case configv3.ColorDisabled: 429 colorPrinter.DisableColor() 430 } 431 432 return colorPrinter.SprintFunc()(text) 433 } 434 435 func sum(intSlice []int) int { 436 sum := 0 437 438 for _, i := range intSlice { 439 sum += i 440 } 441 442 return sum 443 } 444 445 func wordSize(str string) int { 446 cleanStr := vtclean.Clean(str, false) 447 return runewidth.StringWidth(cleanStr) 448 }