Merge pull request #5744 from vvoland/image-tree-chips
image/tree: Chips to represent "in use"
This commit is contained in:
commit
5b90e0e4e5
@ -8,7 +8,6 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/containerd/platforms"
|
||||
"github.com/distribution/reference"
|
||||
@ -17,6 +16,7 @@ import (
|
||||
"github.com/docker/cli/cli/command/completion"
|
||||
"github.com/docker/cli/cli/internal/jsonstream"
|
||||
"github.com/docker/cli/cli/streams"
|
||||
"github.com/docker/cli/internal/tui"
|
||||
"github.com/docker/docker/api/types/auxprogress"
|
||||
"github.com/docker/docker/api/types/image"
|
||||
registrytypes "github.com/docker/docker/api/types/registry"
|
||||
@ -78,6 +78,7 @@ Image index won't be pushed, meaning that other manifests, including attestation
|
||||
//nolint:gocyclo
|
||||
func RunPush(ctx context.Context, dockerCli command.Cli, opts pushOptions) error {
|
||||
var platform *ocispec.Platform
|
||||
out := tui.NewOutput(dockerCli.Out())
|
||||
if opts.platform != "" {
|
||||
p, err := platforms.Parse(opts.platform)
|
||||
if err != nil {
|
||||
@ -86,7 +87,7 @@ func RunPush(ctx context.Context, dockerCli command.Cli, opts pushOptions) error
|
||||
}
|
||||
platform = &p
|
||||
|
||||
printNote(dockerCli, `Using --platform pushes only the specified platform manifest of a multi-platform image index.
|
||||
out.PrintNote(`Using --platform pushes only the specified platform manifest of a multi-platform image index.
|
||||
Other components, like attestations, will not be included.
|
||||
To push the complete multi-platform image, remove the --platform flag.
|
||||
`)
|
||||
@ -132,8 +133,7 @@ To push the complete multi-platform image, remove the --platform flag.
|
||||
|
||||
defer func() {
|
||||
for _, note := range notes {
|
||||
fmt.Fprintln(dockerCli.Err(), "")
|
||||
printNote(dockerCli, note)
|
||||
out.PrintNote(note)
|
||||
}
|
||||
}()
|
||||
|
||||
@ -183,25 +183,3 @@ func handleAux() func(jm jsonstream.JSONMessage) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func printNote(dockerCli command.Cli, format string, args ...any) {
|
||||
if dockerCli.Err().IsTerminal() {
|
||||
format = strings.ReplaceAll(format, "--platform", aec.Bold.Apply("--platform"))
|
||||
}
|
||||
|
||||
header := " Info -> "
|
||||
padding := len(header)
|
||||
if dockerCli.Err().IsTerminal() {
|
||||
padding = len("i Info > ")
|
||||
header = aec.Bold.Apply(aec.LightCyanB.Apply(aec.BlackF.Apply("i")) + " " + aec.LightCyanF.Apply("Info → "))
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprint(dockerCli.Err(), header)
|
||||
s := fmt.Sprintf(format, args...)
|
||||
for idx, line := range strings.Split(s, "\n") {
|
||||
if idx > 0 {
|
||||
_, _ = fmt.Fprint(dockerCli.Err(), strings.Repeat(" ", padding))
|
||||
}
|
||||
_, _ = fmt.Fprintln(dockerCli.Err(), aec.Italic.Apply(line))
|
||||
}
|
||||
}
|
||||
|
@ -5,16 +5,16 @@ import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/containerd/platforms"
|
||||
"github.com/docker/cli/cli/command"
|
||||
"github.com/docker/cli/cli/streams"
|
||||
"github.com/docker/cli/internal/tui"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
imagetypes "github.com/docker/docker/api/types/image"
|
||||
"github.com/docker/docker/pkg/stringid"
|
||||
"github.com/docker/go-units"
|
||||
"github.com/morikuni/aec"
|
||||
"github.com/opencontainers/go-digest"
|
||||
)
|
||||
|
||||
type treeOptions struct {
|
||||
@ -42,6 +42,8 @@ func runTree(ctx context.Context, dockerCLI command.Cli, opts treeOptions) error
|
||||
view := treeView{
|
||||
images: make([]topImage, 0, len(images)),
|
||||
}
|
||||
attested := make(map[digest.Digest]bool)
|
||||
|
||||
for _, img := range images {
|
||||
details := imageDetails{
|
||||
ID: img.ID,
|
||||
@ -52,6 +54,10 @@ func runTree(ctx context.Context, dockerCLI command.Cli, opts treeOptions) error
|
||||
var totalContent int64
|
||||
children := make([]subImage, 0, len(img.Manifests))
|
||||
for _, im := range img.Manifests {
|
||||
if im.Kind == imagetypes.ManifestKindAttestation {
|
||||
attested[im.AttestationData.For] = true
|
||||
continue
|
||||
}
|
||||
if im.Kind != imagetypes.ManifestKindImage {
|
||||
continue
|
||||
}
|
||||
@ -119,8 +125,59 @@ type subImage struct {
|
||||
|
||||
const columnSpacing = 3
|
||||
|
||||
var chipInUse = imageChip{
|
||||
letter: "U",
|
||||
desc: "In Use",
|
||||
fg: 0,
|
||||
bg: 14,
|
||||
check: func(d *imageDetails) bool { return d.InUse },
|
||||
}
|
||||
|
||||
var chipPlaceholder = tui.Str{
|
||||
Plain: " ",
|
||||
Fancy: " ",
|
||||
}
|
||||
|
||||
type imageChip struct {
|
||||
desc string
|
||||
fg, bg int
|
||||
letter string
|
||||
check func(*imageDetails) bool
|
||||
}
|
||||
|
||||
func (c imageChip) String(isTerm bool) string {
|
||||
return tui.Str{
|
||||
Plain: c.letter,
|
||||
Fancy: tui.Chip(c.fg, c.bg, " "+c.letter+" "),
|
||||
}.String(isTerm)
|
||||
}
|
||||
|
||||
var allChips = []imageChip{
|
||||
chipInUse,
|
||||
}
|
||||
|
||||
func getPossibleChips(view treeView) (chips []imageChip) {
|
||||
remaining := make([]imageChip, len(allChips))
|
||||
copy(remaining, allChips)
|
||||
|
||||
var possible []imageChip
|
||||
for _, img := range view.images {
|
||||
for _, c := range img.Children {
|
||||
for idx := len(remaining) - 1; idx >= 0; idx-- {
|
||||
chip := remaining[idx]
|
||||
if chip.check(&c.Details) {
|
||||
possible = append(possible, chip)
|
||||
remaining = append(remaining[:idx], remaining[idx+1:]...)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return possible
|
||||
}
|
||||
|
||||
func printImageTree(dockerCLI command.Cli, view treeView) error {
|
||||
out := dockerCLI.Out()
|
||||
out := tui.NewOutput(dockerCLI.Out())
|
||||
_, width := out.GetTtySize()
|
||||
if width == 0 {
|
||||
width = 80
|
||||
@ -129,24 +186,17 @@ func printImageTree(dockerCLI command.Cli, view treeView) error {
|
||||
width = 20
|
||||
}
|
||||
|
||||
warningColor := aec.LightYellowF
|
||||
headerColor := aec.NewBuilder(aec.DefaultF, aec.Bold).ANSI
|
||||
topNameColor := aec.NewBuilder(aec.BlueF, aec.Bold).ANSI
|
||||
normalColor := aec.NewBuilder(aec.DefaultF).ANSI
|
||||
greenColor := aec.NewBuilder(aec.GreenF).ANSI
|
||||
untaggedColor := aec.NewBuilder(aec.Faint).ANSI
|
||||
if !out.IsTerminal() {
|
||||
headerColor = noColor{}
|
||||
topNameColor = noColor{}
|
||||
normalColor = noColor{}
|
||||
greenColor = noColor{}
|
||||
warningColor = noColor{}
|
||||
untaggedColor = noColor{}
|
||||
}
|
||||
topNameColor := out.Color(aec.NewBuilder(aec.BlueF, aec.Bold).ANSI)
|
||||
normalColor := out.Color(tui.ColorSecondary)
|
||||
untaggedColor := out.Color(tui.ColorTertiary)
|
||||
isTerm := out.IsTerminal()
|
||||
|
||||
_, _ = fmt.Fprintln(out, warningColor.Apply("WARNING: This is an experimental feature. The output may change and shouldn't be depended on."))
|
||||
_, _ = fmt.Fprintln(out, "")
|
||||
out.PrintlnWithColor(tui.ColorWarning, "WARNING: This is an experimental feature. The output may change and shouldn't be depended on.")
|
||||
|
||||
out.Println(generateLegend(out, width))
|
||||
out.Println()
|
||||
|
||||
possibleChips := getPossibleChips(view)
|
||||
columns := []imgColumn{
|
||||
{
|
||||
Title: "Image",
|
||||
@ -178,19 +228,68 @@ func printImageTree(dockerCLI command.Cli, view treeView) error {
|
||||
},
|
||||
},
|
||||
{
|
||||
Title: "In Use",
|
||||
Align: alignCenter,
|
||||
Width: 6,
|
||||
Color: &greenColor,
|
||||
DetailsValue: func(d *imageDetails) string {
|
||||
if d.InUse {
|
||||
return "✔"
|
||||
Title: "Extra",
|
||||
Align: alignLeft,
|
||||
Width: func() int {
|
||||
maxChipsWidth := 0
|
||||
for _, chip := range possibleChips {
|
||||
s := chip.String(isTerm)
|
||||
l := tui.Width(s)
|
||||
maxChipsWidth += l
|
||||
}
|
||||
return " "
|
||||
|
||||
le := len("Extra")
|
||||
if le > maxChipsWidth {
|
||||
return le
|
||||
}
|
||||
return maxChipsWidth
|
||||
}(),
|
||||
Color: &tui.ColorNone,
|
||||
DetailsValue: func(d *imageDetails) string {
|
||||
var out string
|
||||
for _, chip := range possibleChips {
|
||||
if chip.check(d) {
|
||||
out += chip.String(isTerm)
|
||||
} else {
|
||||
out += chipPlaceholder.String(isTerm)
|
||||
}
|
||||
}
|
||||
return out
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
columns = adjustColumns(width, columns, view.images)
|
||||
|
||||
// Print columns
|
||||
for i, h := range columns {
|
||||
if i > 0 {
|
||||
_, _ = fmt.Fprint(out, strings.Repeat(" ", columnSpacing))
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprint(out, h.Print(tui.ColorTitle, strings.ToUpper(h.Title)))
|
||||
}
|
||||
_, _ = fmt.Fprintln(out)
|
||||
|
||||
// Print images
|
||||
for _, img := range view.images {
|
||||
printNames(out, columns, img, topNameColor, untaggedColor)
|
||||
printDetails(out, columns, normalColor, img.Details)
|
||||
|
||||
if len(img.Children) > 0 || view.imageSpacing {
|
||||
_, _ = fmt.Fprintln(out)
|
||||
}
|
||||
printChildren(out, columns, img, normalColor)
|
||||
_, _ = fmt.Fprintln(out)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// adjustColumns adjusts the width of the first column to maximize the space
|
||||
// available for image names and removes any columns that would be too narrow
|
||||
// to display their content.
|
||||
func adjustColumns(width uint, columns []imgColumn, images []topImage) []imgColumn {
|
||||
nameWidth := int(width)
|
||||
for idx, h := range columns {
|
||||
if h.Width == 0 {
|
||||
@ -208,41 +307,35 @@ func printImageTree(dockerCLI command.Cli, view treeView) error {
|
||||
nameWidth -= d
|
||||
}
|
||||
|
||||
images := view.images
|
||||
// Try to make the first column as narrow as possible
|
||||
widest := widestFirstColumnValue(columns, images)
|
||||
if nameWidth > widest {
|
||||
nameWidth = widest
|
||||
}
|
||||
columns[0].Width = nameWidth
|
||||
|
||||
// Print columns
|
||||
for i, h := range columns {
|
||||
if i > 0 {
|
||||
_, _ = fmt.Fprint(out, strings.Repeat(" ", columnSpacing))
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprint(out, h.Print(headerColor, strings.ToUpper(h.Title)))
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(out)
|
||||
|
||||
// Print images
|
||||
for _, img := range images {
|
||||
printNames(out, columns, img, topNameColor, untaggedColor)
|
||||
printDetails(out, columns, normalColor, img.Details)
|
||||
|
||||
if len(img.Children) > 0 || view.imageSpacing {
|
||||
_, _ = fmt.Fprintln(out)
|
||||
}
|
||||
printChildren(out, columns, img, normalColor)
|
||||
_, _ = fmt.Fprintln(out)
|
||||
}
|
||||
|
||||
return nil
|
||||
return columns
|
||||
}
|
||||
|
||||
func printDetails(out *streams.Out, headers []imgColumn, defaultColor aec.ANSI, details imageDetails) {
|
||||
func generateLegend(out tui.Output, width uint) string {
|
||||
var legend string
|
||||
legend += out.Sprint(tui.InfoHeader)
|
||||
for idx, chip := range allChips {
|
||||
legend += " " + out.Sprint(chip) + " " + chip.desc
|
||||
if idx < len(allChips)-1 {
|
||||
legend += " |"
|
||||
}
|
||||
}
|
||||
legend += " "
|
||||
|
||||
r := int(width) - tui.Width(legend)
|
||||
if r < 0 {
|
||||
r = 0
|
||||
}
|
||||
legend = strings.Repeat(" ", r) + legend
|
||||
return legend
|
||||
}
|
||||
|
||||
func printDetails(out tui.Output, headers []imgColumn, defaultColor aec.ANSI, details imageDetails) {
|
||||
for _, h := range headers {
|
||||
if h.DetailsValue == nil {
|
||||
continue
|
||||
@ -258,17 +351,18 @@ func printDetails(out *streams.Out, headers []imgColumn, defaultColor aec.ANSI,
|
||||
}
|
||||
}
|
||||
|
||||
func printChildren(out *streams.Out, headers []imgColumn, img topImage, normalColor aec.ANSI) {
|
||||
func printChildren(out tui.Output, headers []imgColumn, img topImage, normalColor aec.ANSI) {
|
||||
for idx, sub := range img.Children {
|
||||
clr := normalColor
|
||||
if !sub.Available {
|
||||
clr = normalColor.With(aec.Faint)
|
||||
}
|
||||
|
||||
text := sub.Platform
|
||||
if idx != len(img.Children)-1 {
|
||||
_, _ = fmt.Fprint(out, headers[0].Print(clr, "├─ "+sub.Platform))
|
||||
_, _ = fmt.Fprint(out, headers[0].Print(clr, "├─ "+text))
|
||||
} else {
|
||||
_, _ = fmt.Fprint(out, headers[0].Print(clr, "└─ "+sub.Platform))
|
||||
_, _ = fmt.Fprint(out, headers[0].Print(clr, "└─ "+text))
|
||||
}
|
||||
|
||||
printDetails(out, headers, clr, sub.Details)
|
||||
@ -276,7 +370,7 @@ func printChildren(out *streams.Out, headers []imgColumn, img topImage, normalCo
|
||||
}
|
||||
}
|
||||
|
||||
func printNames(out *streams.Out, headers []imgColumn, img topImage, color, untaggedColor aec.ANSI) {
|
||||
func printNames(out tui.Output, headers []imgColumn, img topImage, color, untaggedColor aec.ANSI) {
|
||||
if len(img.Names) == 0 {
|
||||
_, _ = fmt.Fprint(out, headers[0].Print(untaggedColor, "<untagged>"))
|
||||
}
|
||||
@ -294,7 +388,7 @@ func printNames(out *streams.Out, headers []imgColumn, img topImage, color, unta
|
||||
// name will be printed alongside other columns.
|
||||
if nameIdx < len(img.Names)-1 {
|
||||
_, fullWidth := out.GetTtySize()
|
||||
_, _ = fmt.Fprintln(out, color.Apply(truncateRunes(name, int(fullWidth))))
|
||||
_, _ = fmt.Fprintln(out, color.Apply(tui.Ellipsis(name, int(fullWidth))))
|
||||
} else {
|
||||
_, _ = fmt.Fprint(out, headers[0].Print(color, name))
|
||||
}
|
||||
@ -318,14 +412,6 @@ type imgColumn struct {
|
||||
Color *aec.ANSI
|
||||
}
|
||||
|
||||
func truncateRunes(s string, length int) string {
|
||||
runes := []rune(s)
|
||||
if len(runes) > length {
|
||||
return string(runes[:length-1]) + "…"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (h imgColumn) Print(clr aec.ANSI, s string) string {
|
||||
switch h.Align {
|
||||
case alignCenter:
|
||||
@ -338,10 +424,10 @@ func (h imgColumn) Print(clr aec.ANSI, s string) string {
|
||||
}
|
||||
|
||||
func (h imgColumn) PrintC(clr aec.ANSI, s string) string {
|
||||
ln := utf8.RuneCountInString(s)
|
||||
ln := tui.Width(s)
|
||||
|
||||
if ln > h.Width {
|
||||
return clr.Apply(truncateRunes(s, h.Width))
|
||||
return clr.Apply(tui.Ellipsis(s, h.Width))
|
||||
}
|
||||
|
||||
fill := h.Width - ln
|
||||
@ -353,37 +439,23 @@ func (h imgColumn) PrintC(clr aec.ANSI, s string) string {
|
||||
}
|
||||
|
||||
func (h imgColumn) PrintL(clr aec.ANSI, s string) string {
|
||||
ln := utf8.RuneCountInString(s)
|
||||
ln := tui.Width(s)
|
||||
if ln > h.Width {
|
||||
return clr.Apply(truncateRunes(s, h.Width))
|
||||
return clr.Apply(tui.Ellipsis(s, h.Width))
|
||||
}
|
||||
|
||||
return clr.Apply(s) + strings.Repeat(" ", h.Width-ln)
|
||||
}
|
||||
|
||||
func (h imgColumn) PrintR(clr aec.ANSI, s string) string {
|
||||
ln := utf8.RuneCountInString(s)
|
||||
ln := tui.Width(s)
|
||||
if ln > h.Width {
|
||||
return clr.Apply(truncateRunes(s, h.Width))
|
||||
return clr.Apply(tui.Ellipsis(s, h.Width))
|
||||
}
|
||||
|
||||
return strings.Repeat(" ", h.Width-ln) + clr.Apply(s)
|
||||
}
|
||||
|
||||
type noColor struct{}
|
||||
|
||||
func (a noColor) With(_ ...aec.ANSI) aec.ANSI {
|
||||
return a
|
||||
}
|
||||
|
||||
func (a noColor) Apply(s string) string {
|
||||
return s
|
||||
}
|
||||
|
||||
func (a noColor) String() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// widestFirstColumnValue calculates the width needed to fully display the image names and platforms.
|
||||
func widestFirstColumnValue(headers []imgColumn, images []topImage) int {
|
||||
width := len(headers[0].Title)
|
||||
|
9
internal/tui/chip.go
Normal file
9
internal/tui/chip.go
Normal file
@ -0,0 +1,9 @@
|
||||
package tui
|
||||
|
||||
import "strconv"
|
||||
|
||||
func Chip(fg, bg int, content string) string {
|
||||
fgAnsi := "\x1b[38;5;" + strconv.Itoa(fg) + "m"
|
||||
bgAnsi := "\x1b[48;5;" + strconv.Itoa(bg) + "m"
|
||||
return fgAnsi + bgAnsi + content + "\x1b[0m"
|
||||
}
|
30
internal/tui/colors.go
Normal file
30
internal/tui/colors.go
Normal file
@ -0,0 +1,30 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"github.com/morikuni/aec"
|
||||
)
|
||||
|
||||
var (
|
||||
ColorTitle = aec.NewBuilder(aec.DefaultF, aec.Bold).ANSI
|
||||
ColorPrimary = aec.NewBuilder(aec.DefaultF, aec.Bold).ANSI
|
||||
ColorSecondary = aec.DefaultF
|
||||
ColorTertiary = aec.NewBuilder(aec.DefaultF, aec.Faint).ANSI
|
||||
ColorLink = aec.NewBuilder(aec.LightCyanF, aec.Underline).ANSI
|
||||
ColorWarning = aec.LightYellowF
|
||||
ColorFlag = aec.NewBuilder(aec.Bold).ANSI
|
||||
ColorNone = aec.ANSI(noColor{})
|
||||
)
|
||||
|
||||
type noColor struct{}
|
||||
|
||||
func (a noColor) With(_ ...aec.ANSI) aec.ANSI {
|
||||
return a
|
||||
}
|
||||
|
||||
func (a noColor) Apply(s string) string {
|
||||
return s
|
||||
}
|
||||
|
||||
func (a noColor) String() string {
|
||||
return ""
|
||||
}
|
67
internal/tui/count.go
Normal file
67
internal/tui/count.go
Normal file
@ -0,0 +1,67 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/mattn/go-runewidth"
|
||||
)
|
||||
|
||||
func cleanANSI(s string) string {
|
||||
for {
|
||||
start := strings.Index(s, "\x1b")
|
||||
if start == -1 {
|
||||
return s
|
||||
}
|
||||
end := strings.Index(s[start:], "m")
|
||||
if end == -1 {
|
||||
return s
|
||||
}
|
||||
s = s[:start] + s[start+end+1:]
|
||||
}
|
||||
}
|
||||
|
||||
// Width returns the width of the string, ignoring ANSI escape codes.
|
||||
// Not all ANSI escape codes are supported yet.
|
||||
func Width(s string) int {
|
||||
return runewidth.StringWidth(cleanANSI(s))
|
||||
}
|
||||
|
||||
// Ellipsis truncates a string to a given number of runes with an ellipsis at the end.
|
||||
// It tries to persist the ANSI escape sequences.
|
||||
func Ellipsis(s string, length int) string {
|
||||
out := make([]rune, 0, length)
|
||||
ln := 0
|
||||
inEscape := false
|
||||
tooLong := false
|
||||
|
||||
for _, r := range s {
|
||||
if r == '\x1b' {
|
||||
out = append(out, r)
|
||||
inEscape = true
|
||||
continue
|
||||
}
|
||||
if inEscape {
|
||||
out = append(out, r)
|
||||
if r == 'm' {
|
||||
inEscape = false
|
||||
if tooLong {
|
||||
break
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
ln += 1
|
||||
if ln == length {
|
||||
tooLong = true
|
||||
}
|
||||
if !tooLong {
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
|
||||
if tooLong {
|
||||
return string(out) + "…"
|
||||
}
|
||||
return string(out)
|
||||
}
|
36
internal/tui/note.go
Normal file
36
internal/tui/note.go
Normal file
@ -0,0 +1,36 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/morikuni/aec"
|
||||
)
|
||||
|
||||
var InfoHeader = Str{
|
||||
Plain: " Info -> ",
|
||||
Fancy: aec.Bold.Apply(aec.LightCyanB.Apply(aec.BlackF.Apply("i")) + " " + aec.LightCyanF.Apply("Info → ")),
|
||||
}
|
||||
|
||||
func (o Output) PrintNote(format string, args ...any) {
|
||||
if o.isTerminal {
|
||||
// TODO: Handle all flags
|
||||
format = strings.ReplaceAll(format, "--platform", ColorFlag.Apply("--platform"))
|
||||
}
|
||||
|
||||
header := o.Sprint(InfoHeader)
|
||||
|
||||
_, _ = fmt.Fprint(o, "\n", header)
|
||||
s := fmt.Sprintf(format, args...)
|
||||
for idx, line := range strings.Split(s, "\n") {
|
||||
if idx > 0 {
|
||||
_, _ = fmt.Fprint(o, strings.Repeat(" ", Width(header)))
|
||||
}
|
||||
|
||||
l := line
|
||||
if o.isTerminal {
|
||||
l = aec.Italic.Apply(l)
|
||||
}
|
||||
_, _ = fmt.Fprintln(o, l)
|
||||
}
|
||||
}
|
59
internal/tui/output.go
Normal file
59
internal/tui/output.go
Normal file
@ -0,0 +1,59 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/docker/cli/cli/streams"
|
||||
"github.com/morikuni/aec"
|
||||
)
|
||||
|
||||
type Output struct {
|
||||
*streams.Out
|
||||
isTerminal bool
|
||||
}
|
||||
|
||||
type terminalPrintable interface {
|
||||
String(isTerminal bool) string
|
||||
}
|
||||
|
||||
func NewOutput(out *streams.Out) Output {
|
||||
return Output{
|
||||
Out: out,
|
||||
isTerminal: out.IsTerminal(),
|
||||
}
|
||||
}
|
||||
|
||||
func (o Output) Color(clr aec.ANSI) aec.ANSI {
|
||||
if o.isTerminal {
|
||||
return clr
|
||||
}
|
||||
return ColorNone
|
||||
}
|
||||
|
||||
func (o Output) Sprint(all ...any) string {
|
||||
var out []any
|
||||
for _, p := range all {
|
||||
if s, ok := p.(terminalPrintable); ok {
|
||||
out = append(out, s.String(o.isTerminal))
|
||||
} else {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return fmt.Sprint(out...)
|
||||
}
|
||||
|
||||
func (o Output) PrintlnWithColor(clr aec.ANSI, args ...any) {
|
||||
msg := o.Sprint(args...)
|
||||
if o.isTerminal {
|
||||
msg = clr.Apply(msg)
|
||||
}
|
||||
_, _ = fmt.Fprintln(o.Out, msg)
|
||||
}
|
||||
|
||||
func (o Output) Println(p ...any) {
|
||||
_, _ = fmt.Fprintln(o.Out, o.Sprint(p...))
|
||||
}
|
||||
|
||||
func (o Output) Print(p ...any) {
|
||||
_, _ = fmt.Print(o.Out, o.Sprint(p...))
|
||||
}
|
16
internal/tui/str.go
Normal file
16
internal/tui/str.go
Normal file
@ -0,0 +1,16 @@
|
||||
package tui
|
||||
|
||||
type Str struct {
|
||||
// Fancy is the fancy string representation of the string.
|
||||
Fancy string
|
||||
|
||||
// Plain is the plain string representation of the string.
|
||||
Plain string
|
||||
}
|
||||
|
||||
func (p Str) String(isTerminal bool) string {
|
||||
if isTerminal {
|
||||
return p.Fancy
|
||||
}
|
||||
return p.Plain
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user