-
-
Notifications
You must be signed in to change notification settings - Fork 153
Expand file tree
/
Copy pathformatter.go
More file actions
990 lines (848 loc) · 31.1 KB
/
formatter.go
File metadata and controls
990 lines (848 loc) · 31.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
package ui
import (
"fmt"
"strings"
"sync"
"github.com/charmbracelet/glamour"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi"
"github.com/muesli/termenv"
errUtils "github.com/cloudposse/atmos/errors"
"github.com/cloudposse/atmos/pkg/io"
log "github.com/cloudposse/atmos/pkg/logger"
"github.com/cloudposse/atmos/pkg/terminal"
"github.com/cloudposse/atmos/pkg/ui/theme"
)
const (
// Character constants.
newline = "\n"
tab = "\t"
space = " "
// ANSI escape sequences.
clearLine = "\r\x1b[K" // Carriage return + clear from cursor to end of line
// Format templates.
iconMessageFormat = "%s %s"
// Formatting constants.
paragraphIndent = " " // 2-space indent for paragraph continuation
paragraphIndentWidth = 2 // Width of paragraph indent
)
var (
// Global formatter instance and I/O context.
globalIO io.Context
globalFormatter *formatter
globalTerminal terminal.Terminal
formatterMu sync.RWMutex
)
// InitFormatter initializes the global formatter with an I/O context.
// This should be called once at application startup (in root.go).
func InitFormatter(ioCtx io.Context) {
formatterMu.Lock()
defer formatterMu.Unlock()
// Store I/O context for package-level output functions
globalIO = ioCtx
// Create adapter for terminal to write through I/O layer
termWriter := io.NewTerminalWriter(ioCtx)
// Create terminal instance with I/O writer for automatic masking
// terminal.Write() → io.Write(UIStream) → masking → stderr
globalTerminal = terminal.New(terminal.WithIO(termWriter))
// Configure lipgloss global color profile based on terminal capabilities.
// This ensures that all lipgloss styles (including theme.Styles) respect
// terminal color settings like NO_COLOR.
configureColorProfile(globalTerminal)
// Create formatter with I/O context and terminal
globalFormatter = NewFormatter(ioCtx, globalTerminal).(*formatter)
Format = globalFormatter // Also expose for advanced use
}
// Reset clears all UI globals (formatter, I/O, terminal).
// This is primarily used in tests to ensure clean state between test executions.
func Reset() {
formatterMu.Lock()
defer formatterMu.Unlock()
globalIO = nil
globalFormatter = nil
globalTerminal = nil
Format = nil
}
// configureColorProfile sets the global lipgloss color profile based on terminal capabilities.
// This ensures all lipgloss styles respect NO_COLOR and other terminal color settings.
func configureColorProfile(term terminal.Terminal) {
profile := term.ColorProfile()
// Map terminal color profile to termenv profile for lipgloss
var termProfile termenv.Profile
switch profile {
case terminal.ColorNone:
termProfile = termenv.Ascii
case terminal.Color16:
termProfile = termenv.ANSI
case terminal.Color256:
termProfile = termenv.ANSI256
case terminal.ColorTrue:
termProfile = termenv.TrueColor
default:
termProfile = termenv.Ascii
}
setColorProfileInternal(termProfile)
}
// setColorProfileInternal applies a color profile to all color-dependent systems.
// This centralizes the color profile configuration for lipgloss, theme, and logger.
func setColorProfileInternal(profile termenv.Profile) {
// Set the global lipgloss color profile
lipgloss.SetColorProfile(profile)
// Force theme styles to be regenerated with the new color profile.
// This is critical because theme.CurrentStyles caches lipgloss styles that
// bake in ANSI codes at creation time. When the color profile changes,
// we must regenerate the styles.
theme.InvalidateStyleCache()
// Reinitialize logger to respect the new color profile.
// The logger is initialized in init() with a default color profile,
// so we need to explicitly reconfigure it when the color profile changes.
log.Default().SetColorProfile(profile)
}
// SetColorProfile sets the color profile for all UI systems (lipgloss, theme, logger).
// This is primarily intended for testing when environment variables are set after
// package initialization. For normal operation, color profiles are automatically
// configured during InitFormatter() based on terminal capabilities.
//
// Example usage in tests:
//
// t.Setenv("NO_COLOR", "1")
// ui.SetColorProfile(termenv.Ascii)
func SetColorProfile(profile termenv.Profile) {
setColorProfileInternal(profile)
}
// getFormatter returns the global formatter instance.
// Returns error if not initialized instead of panicking.
func getFormatter() (*formatter, error) {
formatterMu.RLock()
defer formatterMu.RUnlock()
if globalFormatter == nil {
return nil, errUtils.ErrUIFormatterNotInitialized
}
return globalFormatter, nil
}
// Package-level functions that delegate to the global formatter.
// Markdown writes rendered markdown to stdout (data channel).
// Use this for help text, documentation, and other pipeable formatted content.
// Note: Delegates to globalFormatter.Markdown() for rendering, then writes to data channel.
func Markdown(content string) error {
formatterMu.RLock()
defer formatterMu.RUnlock()
if globalFormatter == nil || globalIO == nil {
return errUtils.ErrUIFormatterNotInitialized
}
rendered, err := globalFormatter.Markdown(content)
if err != nil {
// Degrade gracefully - write plain content if rendering fails
rendered = content
}
_, writeErr := fmt.Fprint(globalIO.Data(), rendered)
return writeErr
}
// Markdownf writes formatted markdown to stdout (data channel).
func Markdownf(format string, a ...interface{}) error {
content := fmt.Sprintf(format, a...)
return Markdown(content)
}
// MarkdownMessage writes rendered markdown to stderr (UI channel).
// Use this for formatted UI messages and errors.
func MarkdownMessage(content string) error {
formatterMu.RLock()
defer formatterMu.RUnlock()
if globalFormatter == nil || globalIO == nil {
return errUtils.ErrUIFormatterNotInitialized
}
rendered, err := globalFormatter.Markdown(content)
if err != nil {
// Degrade gracefully - write plain content if rendering fails
rendered = content
}
_, writeErr := fmt.Fprint(globalIO.UI(), rendered)
return writeErr
}
// MarkdownMessagef writes formatted markdown to stderr (UI channel).
func MarkdownMessagef(format string, a ...interface{}) error {
content := fmt.Sprintf(format, a...)
return MarkdownMessage(content)
}
// Success writes a success message with green checkmark to stderr (UI channel).
// Flow: ui.Success() → terminal.Write() → io.Write(UIStream) → masking → stderr.
func Success(text string) error {
f, err := getFormatter()
if err != nil {
return err
}
formatted := f.Success(text) + newline
return f.terminal.Write(formatted)
}
// Successf writes a formatted success message with green checkmark to stderr (UI channel).
// Flow: ui.Successf() → terminal.Write() → io.Write(UIStream) → masking → stderr.
func Successf(format string, a ...interface{}) error {
f, err := getFormatter()
if err != nil {
return err
}
formatted := f.Successf(format, a...) + newline
return f.terminal.Write(formatted)
}
// Error writes an error message with red X to stderr (UI channel).
// Flow: ui.Error() → terminal.Write() → io.Write(UIStream) → masking → stderr.
func Error(text string) error {
f, err := getFormatter()
if err != nil {
return err
}
formatted := f.Error(text) + newline
return f.terminal.Write(formatted)
}
// Errorf writes a formatted error message with red X to stderr (UI channel).
// Flow: ui.Errorf() → terminal.Write() → io.Write(UIStream) → masking → stderr.
func Errorf(format string, a ...interface{}) error {
f, err := getFormatter()
if err != nil {
return err
}
formatted := f.Errorf(format, a...) + newline
return f.terminal.Write(formatted)
}
// Warning writes a warning message with yellow warning sign to stderr (UI channel).
// Flow: ui.Warning() → terminal.Write() → io.Write(UIStream) → masking → stderr.
func Warning(text string) error {
f, err := getFormatter()
if err != nil {
return err
}
formatted := f.Warning(text) + newline
return f.terminal.Write(formatted)
}
// Warningf writes a formatted warning message with yellow warning sign to stderr (UI channel).
// Flow: ui.Warningf() → terminal.Write() → io.Write(UIStream) → masking → stderr.
func Warningf(format string, a ...interface{}) error {
f, err := getFormatter()
if err != nil {
return err
}
formatted := f.Warningf(format, a...) + newline
return f.terminal.Write(formatted)
}
// Info writes an info message with cyan info icon to stderr (UI channel).
// Flow: ui.Info() → terminal.Write() → io.Write(UIStream) → masking → stderr.
func Info(text string) error {
f, err := getFormatter()
if err != nil {
return err
}
formatted := f.Info(text) + newline
return f.terminal.Write(formatted)
}
// Infof writes a formatted info message with cyan info icon to stderr (UI channel).
// Flow: ui.Infof() → terminal.Write() → io.Write(UIStream) → masking → stderr.
func Infof(format string, a ...interface{}) error {
f, err := getFormatter()
if err != nil {
return err
}
formatted := f.Infof(format, a...) + newline
return f.terminal.Write(formatted)
}
// Toast writes a toast message with custom icon to stderr (UI channel).
// Flow: ui.Toast() → terminal.Write() → io.Write(UIStream) → masking → stderr.
func Toast(icon, message string) error {
f, err := getFormatter()
if err != nil {
return err
}
formatted := f.Toast(icon, message) // formatter.Toast() already includes trailing newline
return f.terminal.Write(formatted)
}
// Toastf writes a formatted toast message with custom icon to stderr (UI channel).
// Flow: ui.Toastf() → terminal.Write() → io.Write(UIStream) → masking → stderr.
func Toastf(icon, format string, a ...interface{}) error {
f, err := getFormatter()
if err != nil {
return err
}
formatted := f.Toastf(icon, format, a...) // formatter.Toastf() already includes trailing newline
return f.terminal.Write(formatted)
}
// Hint writes a hint/tip message with lightbulb icon to stderr (UI channel).
// This is a convenience wrapper with themed hint icon and muted color.
// Flow: ui.Hint() → terminal.Write() → io.Write(UIStream) → masking → stderr.
func Hint(text string) error {
f, err := getFormatter()
if err != nil {
return err
}
formatted := f.Hint(text) + newline
return f.terminal.Write(formatted)
}
// Hintf writes a formatted hint/tip message with lightbulb icon to stderr (UI channel).
// This is a convenience wrapper with themed hint icon and muted color.
// Flow: ui.Hintf() → terminal.Write() → io.Write(UIStream) → masking → stderr.
func Hintf(format string, a ...interface{}) error {
f, err := getFormatter()
if err != nil {
return err
}
formatted := f.Hintf(format, a...) + newline
return f.terminal.Write(formatted)
}
// Write writes plain text to stderr (UI channel) without icons or automatic styling.
// Flow: ui.Write() → terminal.Write() → io.Write(UIStream) → masking → stderr.
func Write(text string) error {
f, err := getFormatter()
if err != nil {
return err
}
return f.terminal.Write(text)
}
// FormatSuccess returns a success message with green checkmark as a formatted string.
// Use this when you need the formatted string without writing (e.g., in bubbletea views).
func FormatSuccess(text string) string {
f, err := getFormatter()
if err != nil {
// Fallback to unformatted
return "✓ " + text
}
return f.Success(text)
}
// FormatError returns an error message with red X as a formatted string.
// Use this when you need the formatted string without writing (e.g., in bubbletea views).
func FormatError(text string) string {
f, err := getFormatter()
if err != nil {
// Fallback to unformatted
return "✗ " + text
}
return f.Error(text)
}
// Writef writes formatted text to stderr (UI channel) without icons or automatic styling.
// Flow: ui.Writef() → terminal.Write() → io.Write(UIStream) → masking → stderr.
func Writef(format string, a ...interface{}) error {
return Write(fmt.Sprintf(format, a...))
}
// Writeln writes text followed by a newline to stderr (UI channel) without icons or automatic styling.
// Flow: ui.Writeln() → terminal.Write() → io.Write(UIStream) → masking → stderr.
func Writeln(text string) error {
return Write(text + newline)
}
// ClearLine clears the current line in the terminal and returns cursor to the beginning.
// Respects NO_COLOR and terminal capabilities - uses ANSI escape sequences only when supported.
// When colors are disabled, only writes carriage return to move cursor to start of line.
// This is useful for replacing spinner messages or other dynamic output with final status messages.
// Flow: ui.ClearLine() → ui.Write() → terminal.Write() → io.Write(UIStream) → masking → stderr.
//
// Example usage:
//
// // Clear spinner line and show success message
// _ = ui.ClearLine()
// _ = ui.Success("Operation completed successfully")
func ClearLine() error {
formatterMu.RLock()
defer formatterMu.RUnlock()
if globalTerminal == nil {
return errUtils.ErrUIFormatterNotInitialized
}
// Only use ANSI clear sequence if terminal supports colors.
// When NO_COLOR=1 or color is disabled, just use carriage return.
if globalTerminal.ColorProfile() != terminal.ColorNone {
return Write(clearLine) // \r\x1b[K - carriage return + clear to EOL
}
return Write("\r") // Just carriage return when colors disabled
}
// Format exposes the global formatter for advanced use cases.
// Most code should use the package-level functions (ui.Success, ui.Error, etc.).
// Use this when you need the formatted string without writing it.
var Format Formatter
// formatter implements the Formatter interface.
type formatter struct {
ioCtx io.Context
terminal terminal.Terminal
styles *theme.StyleSet
}
// NewFormatter creates a new Formatter with I/O context and terminal.
// Most code should use the package-level functions instead (ui.Markdown, ui.Success, etc.).
func NewFormatter(ioCtx io.Context, term terminal.Terminal) Formatter {
// Use theme-aware styles based on configured theme
styles := theme.GetCurrentStyles()
return &formatter{
ioCtx: ioCtx,
terminal: term,
styles: styles,
}
}
func (f *formatter) Styles() *theme.StyleSet {
return f.styles
}
func (f *formatter) SupportsColor() bool {
return f.terminal.ColorProfile() != terminal.ColorNone
}
func (f *formatter) ColorProfile() terminal.ColorProfile {
return f.terminal.ColorProfile()
}
// StatusMessage formats a message with an icon and color.
// This is the foundational method used by Success, Error, Warning, and Info.
//
// Parameters:
// - icon: The icon/symbol to prefix the message (e.g., "✓", "✗", "⚠", "ℹ")
// - style: The lipgloss style to apply (determines color)
// - text: The message text
//
// Returns formatted string: "{colored icon} {text}" where only the icon is colored.
func (f *formatter) StatusMessage(icon string, style *lipgloss.Style, text string) string {
if !f.SupportsColor() {
return fmt.Sprintf(iconMessageFormat, icon, text)
}
// Style only the icon, not the entire message.
styledIcon := style.Render(icon)
return fmt.Sprintf(iconMessageFormat, styledIcon, text)
}
// Toast renders markdown text with an icon prefix and auto-indents multi-line content.
// Returns the formatted string with a trailing newline.
func (f *formatter) Toast(icon, message string) string {
result, _ := f.toastMarkdown(icon, nil, message)
return result + newline
}
// Toastf renders formatted markdown text with an icon prefix.
func (f *formatter) Toastf(icon, format string, a ...interface{}) string {
return f.Toast(icon, fmt.Sprintf(format, a...))
}
// isANSIStart checks if position i marks the start of an ANSI escape sequence.
func isANSIStart(s string, i int) bool {
return s[i] == '\x1b' && i+1 < len(s) && s[i+1] == '['
}
// skipANSISequence advances past an ANSI escape sequence starting at position i.
// Returns the index after the sequence terminator.
func skipANSISequence(s string, i int) int {
i += 2 // Skip ESC and [.
for i < len(s) && !isANSITerminator(s[i]) {
i++
}
if i < len(s) {
i++ // Skip terminator.
}
return i
}
// isANSITerminator checks if byte b is an ANSI sequence terminator (A-Z or a-z).
func isANSITerminator(b byte) bool {
return (b >= 'A' && b <= 'Z') || (b >= 'a' && b <= 'z')
}
// copyContentAndANSI copies characters and ANSI codes from s until plainIdx reaches targetLen.
// Returns the result builder pointer and the final position in s.
func copyContentAndANSI(s string, targetLen int) (*strings.Builder, int) {
result := &strings.Builder{}
plainIdx := 0
i := 0
for i < len(s) && plainIdx < targetLen {
if isANSIStart(s, i) {
start := i
i = skipANSISequence(s, i)
result.WriteString(s[start:i])
} else {
result.WriteByte(s[i])
plainIdx++
i++
}
}
return result, i
}
// trimRightSpaces removes only trailing spaces (not tabs) from an ANSI-coded string while
// preserving all ANSI escape sequences on the actual content.
// This is useful for removing Glamour's padding spaces while preserving intentional tabs.
func trimRightSpaces(s string) string {
stripped := ansi.Strip(s)
trimmed := strings.TrimRight(stripped, " ")
if trimmed == stripped {
return s
}
if trimmed == "" {
return ""
}
result, i := copyContentAndANSI(s, len(trimmed))
// Capture any trailing ANSI codes that immediately follow the last character.
for i < len(s) && isANSIStart(s, i) {
start := i
i = skipANSISequence(s, i)
result.WriteString(s[start:i])
}
return result.String()
}
// trimLeftSpaces removes only leading spaces from an ANSI-coded string while
// preserving all ANSI escape sequences on the remaining content.
// This is useful for removing Glamour's paragraph indent while preserving styled content.
func trimLeftSpaces(s string) string {
stripped := ansi.Strip(s)
trimmed := strings.TrimLeft(stripped, " ")
if trimmed == stripped {
return s // No leading spaces to remove.
}
if trimmed == "" {
return "" // All spaces.
}
// Calculate how many leading spaces to skip.
leadingSpaces := len(stripped) - len(trimmed)
// Walk through original string, skipping ANSI codes and counting spaces.
spacesSkipped := 0
i := 0
// Skip leading ANSI codes and spaces until we've skipped the required amount.
skipLoop:
for i < len(s) && spacesSkipped < leadingSpaces {
switch {
case isANSIStart(s, i):
// Skip ANSI sequence (don't output it since it's styling skipped content).
i = skipANSISequence(s, i)
case s[i] == ' ':
spacesSkipped++
i++
default:
break skipLoop // Non-space content found.
}
}
// Return remaining content (including any ANSI codes).
return s[i:]
}
// isWhitespace checks if byte b is a space or tab.
func isWhitespace(b byte) bool {
return b == ' ' || b == '\t'
}
// processTrailingANSICodes processes ANSI codes after content, preserving reset codes
// but not color codes that wrap trailing whitespace.
func processTrailingANSICodes(s string, i int, result *strings.Builder) {
for i < len(s) && isANSIStart(s, i) {
start := i
i = skipANSISequence(s, i)
// Check what comes after this ANSI code.
if shouldIncludeTrailingANSI(s, i, start, result) {
return
}
}
}
// shouldIncludeTrailingANSI determines whether to include a trailing ANSI code and stop processing.
// Returns true if processing should stop.
func shouldIncludeTrailingANSI(s string, i, start int, result *strings.Builder) bool {
// Whitespace or end of string directly after this code - include and stop.
if i >= len(s) || isWhitespace(s[i]) {
result.WriteString(s[start:i])
return true
}
// Another ANSI code follows - peek ahead.
if isANSIStart(s, i) {
nextEnd := skipANSISequence(s, i)
if nextEnd < len(s) && isWhitespace(s[nextEnd]) {
// Next code wraps whitespace - include current and stop.
result.WriteString(s[start:i])
return true
}
// Next code doesn't wrap whitespace - include and continue.
result.WriteString(s[start:i])
return false
}
// Other content follows - include the code.
result.WriteString(s[start:i])
return false
}
// TrimRight removes trailing whitespace from an ANSI-coded string while
// preserving all ANSI escape sequences on the actual content.
// This is useful for removing Glamour's padding spaces that are wrapped in ANSI codes.
func TrimRight(s string) string {
stripped := ansi.Strip(s)
trimmed := strings.TrimRight(stripped, " \t")
if trimmed == stripped {
return s
}
if trimmed == "" {
return ""
}
result, i := copyContentAndANSI(s, len(trimmed))
processTrailingANSICodes(s, i, result)
return result.String()
}
// TrimLinesRight trims trailing whitespace from each line in a multi-line string.
// This is useful after lipgloss.Render() which pads all lines to the same width.
// Uses ANSI-aware TrimRight to handle whitespace wrapped in ANSI codes.
func TrimLinesRight(s string) string {
lines := strings.Split(s, newline)
for i, line := range lines {
lines[i] = TrimRight(line)
}
return strings.Join(lines, newline)
}
// trimTrailingWhitespace splits rendered markdown by newlines and trims trailing spaces
// that Glamour adds for padding (including ANSI-wrapped spaces). For empty lines (all whitespace),
// it preserves the leading indent (first 2 spaces) to maintain paragraph structure.
func trimTrailingWhitespace(rendered string) []string {
lines := strings.Split(rendered, newline)
for i := range lines {
// Use trimRightSpaces to remove trailing spaces while preserving tabs
line := trimRightSpaces(lines[i])
// If line became empty after trimming but had content before,
// it was an empty line with indent - preserve the indent
if line == "" && len(lines[i]) > 0 {
// Preserve up to 2 leading spaces for paragraph indent
if len(lines[i]) >= paragraphIndentWidth {
lines[i] = paragraphIndent
} else {
lines[i] = lines[i][:len(lines[i])] // Keep whatever spaces there were
}
} else {
lines[i] = line
}
}
return lines
}
// toastMarkdown renders markdown text with preserved newlines, an icon prefix, and auto-indents multi-line content.
// Uses a compact stylesheet for toast-style inline formatting.
func (f *formatter) toastMarkdown(icon string, style *lipgloss.Style, text string) (string, error) {
// Render markdown with toast-specific compact stylesheet
rendered, err := f.renderToastMarkdown(text)
if err != nil {
return "", err
}
// Glamour adds 1 leading newline and 2 trailing newlines to every output.
// Remove these, but preserve any newlines that were in the original message.
rendered = strings.TrimPrefix(rendered, newline) // Remove Glamour's leading newline
rendered = strings.TrimSuffix(rendered, newline+newline) // Remove Glamour's trailing newlines
// If there's still a trailing newline, it was from the original message.
if !strings.HasSuffix(rendered, newline) && strings.HasSuffix(text, newline) {
// Original had trailing newline but rendering lost it, add it back.
rendered += newline
}
// Style the icon if color is supported
var styledIcon string
if f.SupportsColor() && style != nil {
styledIcon = style.Render(icon)
} else {
styledIcon = icon
}
// Split by newlines and trim trailing padding that Glamour adds
lines := trimTrailingWhitespace(rendered)
if len(lines) == 0 {
return styledIcon, nil
}
if len(lines) == 1 {
// For single line: trim leading spaces from Glamour's paragraph indent
// since the icon+space already provides visual separation.
// Use ANSI-aware trimming since Glamour may wrap spaces in color codes.
line := trimLeftSpaces(lines[0])
return fmt.Sprintf(iconMessageFormat, styledIcon, line), nil
}
// Multi-line: trim leading spaces from first line (goes next to icon).
// Use ANSI-aware trimming since Glamour may wrap spaces in color codes.
lines[0] = trimLeftSpaces(lines[0])
// Multi-line: first line with icon, rest indented to align under first line's text
result := fmt.Sprintf(iconMessageFormat, styledIcon, lines[0])
// Calculate indent: icon width + 1 space from iconMessageFormat
// Use lipgloss.Width to handle multi-cell characters like emojis
iconWidth := lipgloss.Width(icon)
indent := strings.Repeat(space, iconWidth+1) // +1 for the space in "%s %s" format
for i := 1; i < len(lines); i++ {
// Glamour already added 2-space paragraph indent, replace with our calculated indent.
// Use ANSI-aware trimming since Glamour may wrap spaces in color codes.
line := trimLeftSpaces(lines[i])
result += newline + indent + line
}
return result, nil
}
// renderToastMarkdown renders markdown with a compact stylesheet for toast messages.
func (f *formatter) renderToastMarkdown(content string) (string, error) {
// Build glamour options with compact toast stylesheet
var opts []glamour.TermRendererOption
// Enable word wrap for toast messages to respect terminal width.
// Note: Glamour adds padding to fill width - we trim it with trimTrailingWhitespace().
maxWidth := f.ioCtx.Config().AtmosConfig.Settings.Terminal.MaxWidth
if maxWidth == 0 {
// Use terminal width if available
termWidth := f.terminal.Width(terminal.Stdout)
if termWidth > 0 {
maxWidth = termWidth
}
}
if maxWidth > 0 {
opts = append(opts, glamour.WithWordWrap(maxWidth))
}
opts = append(opts, glamour.WithPreservedNewLines())
// Get theme-based glamour style and modify it for compact toast rendering
if f.terminal.ColorProfile() != terminal.ColorNone {
themeName := f.ioCtx.Config().AtmosConfig.Settings.Terminal.Theme
if themeName == "" {
themeName = "default"
}
glamourStyle, err := theme.GetGlamourStyleForTheme(themeName)
if err == nil {
// Modify the theme style to have zero margins
// Parse the existing theme and override margin settings
opts = append(opts, glamour.WithStylesFromJSONBytes(glamourStyle))
}
} else {
opts = append(opts, glamour.WithStylePath("notty"))
}
renderer, err := glamour.NewTermRenderer(opts...)
if err != nil {
// Degrade gracefully: return plain content if renderer creation fails
return content, err
}
defer renderer.Close()
rendered, err := renderer.Render(content)
if err != nil {
// Degrade gracefully: return plain content if rendering fails
return content, err
}
return rendered, nil
}
// Semantic formatting - all use toastMarkdown for markdown rendering and icon styling.
func (f *formatter) Success(text string) string {
result, _ := f.toastMarkdown("✓", &f.styles.Success, text)
return result
}
func (f *formatter) Successf(format string, a ...interface{}) string {
return f.Success(fmt.Sprintf(format, a...))
}
func (f *formatter) Warning(text string) string {
result, _ := f.toastMarkdown("⚠", &f.styles.Warning, text)
return result
}
func (f *formatter) Warningf(format string, a ...interface{}) string {
return f.Warning(fmt.Sprintf(format, a...))
}
func (f *formatter) Error(text string) string {
result, _ := f.toastMarkdown("✗", &f.styles.Error, text)
return result
}
func (f *formatter) Errorf(format string, a ...interface{}) string {
return f.Error(fmt.Sprintf(format, a...))
}
func (f *formatter) Info(text string) string {
result, _ := f.toastMarkdown("ℹ", &f.styles.Info, text)
return result
}
func (f *formatter) Infof(format string, a ...interface{}) string {
return f.Info(fmt.Sprintf(format, a...))
}
func (f *formatter) Hint(text string) string {
// Render the icon with muted style.
var styledIcon string
if f.SupportsColor() {
styledIcon = f.styles.Muted.Render("💡")
} else {
styledIcon = "💡"
}
// Render the text with inline markdown and apply muted style.
styledText := f.renderInlineMarkdownWithBase(text, &f.styles.Muted)
return fmt.Sprintf(iconMessageFormat, styledIcon, styledText)
}
// renderInlineMarkdownWithBase renders inline markdown and applies a base style to the result.
// This is useful for rendering markdown within styled contexts like hints.
func (f *formatter) renderInlineMarkdownWithBase(text string, baseStyle *lipgloss.Style) string {
// Render markdown using toast renderer for compact inline formatting.
rendered, err := f.renderToastMarkdown(text)
if err != nil {
// Degrade gracefully: apply base style to plain text.
if f.SupportsColor() && baseStyle != nil {
return baseStyle.Render(text)
}
return text
}
// Clean up Glamour's extra newlines.
rendered = strings.TrimPrefix(rendered, newline)
rendered = strings.TrimSuffix(rendered, newline+newline)
rendered = strings.TrimSuffix(rendered, newline)
// Trim trailing padding and leading indent from Glamour.
lines := trimTrailingWhitespace(rendered)
if len(lines) == 0 {
return ""
}
// For single line, trim leading spaces.
// Use ANSI-aware trimming since Glamour may wrap spaces in color codes.
if len(lines) == 1 {
rendered = trimLeftSpaces(lines[0])
} else {
// Multi-line: trim first line and rejoin.
// Use ANSI-aware trimming since Glamour may wrap spaces in color codes.
lines[0] = trimLeftSpaces(lines[0])
rendered = strings.Join(lines, newline)
}
// Apply base style if color is supported.
if f.SupportsColor() && baseStyle != nil {
return baseStyle.Render(rendered)
}
return rendered
}
func (f *formatter) Hintf(format string, a ...interface{}) string {
return f.Hint(fmt.Sprintf(format, a...))
}
func (f *formatter) Muted(text string) string {
if !f.SupportsColor() {
return text
}
return f.styles.Muted.Render(text)
}
func (f *formatter) Bold(text string) string {
if !f.SupportsColor() {
return text
}
return f.styles.Title.Render(text)
}
func (f *formatter) Heading(text string) string {
if !f.SupportsColor() {
return text
}
return f.styles.Heading.Render(text)
}
func (f *formatter) Label(text string) string {
if !f.SupportsColor() {
return text
}
return f.styles.Label.Render(text)
}
// Markdown returns the rendered markdown string (pure function, no I/O).
// For writing markdown to channels, use package-level ui.Markdown() or ui.MarkdownMessage().
func (f *formatter) Markdown(content string) (string, error) {
return f.renderMarkdown(content, false)
}
// renderMarkdown is the internal markdown rendering implementation.
func (f *formatter) renderMarkdown(content string, preserveNewlines bool) (string, error) {
// Determine max width from config or terminal
maxWidth := f.ioCtx.Config().AtmosConfig.Settings.Terminal.MaxWidth
if maxWidth == 0 {
// Use terminal width if available
termWidth := f.terminal.Width(terminal.Stdout)
if termWidth > 0 {
maxWidth = termWidth
}
}
// Build glamour options with theme-aware styling
var opts []glamour.TermRendererOption
if maxWidth > 0 {
opts = append(opts, glamour.WithWordWrap(maxWidth))
}
// Preserve newlines if requested
if preserveNewlines {
opts = append(opts, glamour.WithPreservedNewLines())
}
// Use theme-aware glamour styles
if f.terminal.ColorProfile() != terminal.ColorNone {
themeName := f.ioCtx.Config().AtmosConfig.Settings.Terminal.Theme
if themeName == "" {
themeName = "default"
}
glamourStyle, err := theme.GetGlamourStyleForTheme(themeName)
if err == nil {
opts = append(opts, glamour.WithStylesFromJSONBytes(glamourStyle))
}
// Fallback to notty style if theme conversion fails
} else {
opts = append(opts, glamour.WithStylePath("notty"))
}
renderer, err := glamour.NewTermRenderer(opts...)
if err != nil {
// Degrade gracefully: return plain content if renderer creation fails
return content, err
}
defer renderer.Close()
rendered, err := renderer.Render(content)
if err != nil {
// Degrade gracefully: return plain content if rendering fails
return content, err
}
// Remove trailing whitespace that glamour adds for padding.
return TrimLinesRight(rendered), nil
}