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"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/containerd/platforms"
|
"github.com/containerd/platforms"
|
||||||
"github.com/distribution/reference"
|
"github.com/distribution/reference"
|
||||||
@ -17,6 +16,7 @@ import (
|
|||||||
"github.com/docker/cli/cli/command/completion"
|
"github.com/docker/cli/cli/command/completion"
|
||||||
"github.com/docker/cli/cli/internal/jsonstream"
|
"github.com/docker/cli/cli/internal/jsonstream"
|
||||||
"github.com/docker/cli/cli/streams"
|
"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/auxprogress"
|
||||||
"github.com/docker/docker/api/types/image"
|
"github.com/docker/docker/api/types/image"
|
||||||
registrytypes "github.com/docker/docker/api/types/registry"
|
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
|
//nolint:gocyclo
|
||||||
func RunPush(ctx context.Context, dockerCli command.Cli, opts pushOptions) error {
|
func RunPush(ctx context.Context, dockerCli command.Cli, opts pushOptions) error {
|
||||||
var platform *ocispec.Platform
|
var platform *ocispec.Platform
|
||||||
|
out := tui.NewOutput(dockerCli.Out())
|
||||||
if opts.platform != "" {
|
if opts.platform != "" {
|
||||||
p, err := platforms.Parse(opts.platform)
|
p, err := platforms.Parse(opts.platform)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -86,7 +87,7 @@ func RunPush(ctx context.Context, dockerCli command.Cli, opts pushOptions) error
|
|||||||
}
|
}
|
||||||
platform = &p
|
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.
|
Other components, like attestations, will not be included.
|
||||||
To push the complete multi-platform image, remove the --platform flag.
|
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() {
|
defer func() {
|
||||||
for _, note := range notes {
|
for _, note := range notes {
|
||||||
fmt.Fprintln(dockerCli.Err(), "")
|
out.PrintNote(note)
|
||||||
printNote(dockerCli, 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"
|
"fmt"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"unicode/utf8"
|
|
||||||
|
|
||||||
"github.com/containerd/platforms"
|
"github.com/containerd/platforms"
|
||||||
"github.com/docker/cli/cli/command"
|
"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"
|
"github.com/docker/docker/api/types/filters"
|
||||||
imagetypes "github.com/docker/docker/api/types/image"
|
imagetypes "github.com/docker/docker/api/types/image"
|
||||||
"github.com/docker/docker/pkg/stringid"
|
"github.com/docker/docker/pkg/stringid"
|
||||||
"github.com/docker/go-units"
|
"github.com/docker/go-units"
|
||||||
"github.com/morikuni/aec"
|
"github.com/morikuni/aec"
|
||||||
|
"github.com/opencontainers/go-digest"
|
||||||
)
|
)
|
||||||
|
|
||||||
type treeOptions struct {
|
type treeOptions struct {
|
||||||
@ -42,6 +42,8 @@ func runTree(ctx context.Context, dockerCLI command.Cli, opts treeOptions) error
|
|||||||
view := treeView{
|
view := treeView{
|
||||||
images: make([]topImage, 0, len(images)),
|
images: make([]topImage, 0, len(images)),
|
||||||
}
|
}
|
||||||
|
attested := make(map[digest.Digest]bool)
|
||||||
|
|
||||||
for _, img := range images {
|
for _, img := range images {
|
||||||
details := imageDetails{
|
details := imageDetails{
|
||||||
ID: img.ID,
|
ID: img.ID,
|
||||||
@ -52,6 +54,10 @@ func runTree(ctx context.Context, dockerCLI command.Cli, opts treeOptions) error
|
|||||||
var totalContent int64
|
var totalContent int64
|
||||||
children := make([]subImage, 0, len(img.Manifests))
|
children := make([]subImage, 0, len(img.Manifests))
|
||||||
for _, im := range img.Manifests {
|
for _, im := range img.Manifests {
|
||||||
|
if im.Kind == imagetypes.ManifestKindAttestation {
|
||||||
|
attested[im.AttestationData.For] = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
if im.Kind != imagetypes.ManifestKindImage {
|
if im.Kind != imagetypes.ManifestKindImage {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@ -119,8 +125,59 @@ type subImage struct {
|
|||||||
|
|
||||||
const columnSpacing = 3
|
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 {
|
func printImageTree(dockerCLI command.Cli, view treeView) error {
|
||||||
out := dockerCLI.Out()
|
out := tui.NewOutput(dockerCLI.Out())
|
||||||
_, width := out.GetTtySize()
|
_, width := out.GetTtySize()
|
||||||
if width == 0 {
|
if width == 0 {
|
||||||
width = 80
|
width = 80
|
||||||
@ -129,24 +186,17 @@ func printImageTree(dockerCLI command.Cli, view treeView) error {
|
|||||||
width = 20
|
width = 20
|
||||||
}
|
}
|
||||||
|
|
||||||
warningColor := aec.LightYellowF
|
topNameColor := out.Color(aec.NewBuilder(aec.BlueF, aec.Bold).ANSI)
|
||||||
headerColor := aec.NewBuilder(aec.DefaultF, aec.Bold).ANSI
|
normalColor := out.Color(tui.ColorSecondary)
|
||||||
topNameColor := aec.NewBuilder(aec.BlueF, aec.Bold).ANSI
|
untaggedColor := out.Color(tui.ColorTertiary)
|
||||||
normalColor := aec.NewBuilder(aec.DefaultF).ANSI
|
isTerm := out.IsTerminal()
|
||||||
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{}
|
|
||||||
}
|
|
||||||
|
|
||||||
_, _ = fmt.Fprintln(out, warningColor.Apply("WARNING: This is an experimental feature. The output may change and shouldn't be depended on."))
|
out.PrintlnWithColor(tui.ColorWarning, "WARNING: This is an experimental feature. The output may change and shouldn't be depended on.")
|
||||||
_, _ = fmt.Fprintln(out, "")
|
|
||||||
|
|
||||||
|
out.Println(generateLegend(out, width))
|
||||||
|
out.Println()
|
||||||
|
|
||||||
|
possibleChips := getPossibleChips(view)
|
||||||
columns := []imgColumn{
|
columns := []imgColumn{
|
||||||
{
|
{
|
||||||
Title: "Image",
|
Title: "Image",
|
||||||
@ -178,19 +228,68 @@ func printImageTree(dockerCLI command.Cli, view treeView) error {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Title: "In Use",
|
Title: "Extra",
|
||||||
Align: alignCenter,
|
Align: alignLeft,
|
||||||
Width: 6,
|
Width: func() int {
|
||||||
Color: &greenColor,
|
maxChipsWidth := 0
|
||||||
DetailsValue: func(d *imageDetails) string {
|
for _, chip := range possibleChips {
|
||||||
if d.InUse {
|
s := chip.String(isTerm)
|
||||||
return "✔"
|
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)
|
nameWidth := int(width)
|
||||||
for idx, h := range columns {
|
for idx, h := range columns {
|
||||||
if h.Width == 0 {
|
if h.Width == 0 {
|
||||||
@ -208,41 +307,35 @@ func printImageTree(dockerCLI command.Cli, view treeView) error {
|
|||||||
nameWidth -= d
|
nameWidth -= d
|
||||||
}
|
}
|
||||||
|
|
||||||
images := view.images
|
|
||||||
// Try to make the first column as narrow as possible
|
// Try to make the first column as narrow as possible
|
||||||
widest := widestFirstColumnValue(columns, images)
|
widest := widestFirstColumnValue(columns, images)
|
||||||
if nameWidth > widest {
|
if nameWidth > widest {
|
||||||
nameWidth = widest
|
nameWidth = widest
|
||||||
}
|
}
|
||||||
columns[0].Width = nameWidth
|
columns[0].Width = nameWidth
|
||||||
|
return columns
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
for _, h := range headers {
|
||||||
if h.DetailsValue == nil {
|
if h.DetailsValue == nil {
|
||||||
continue
|
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 {
|
for idx, sub := range img.Children {
|
||||||
clr := normalColor
|
clr := normalColor
|
||||||
if !sub.Available {
|
if !sub.Available {
|
||||||
clr = normalColor.With(aec.Faint)
|
clr = normalColor.With(aec.Faint)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
text := sub.Platform
|
||||||
if idx != len(img.Children)-1 {
|
if idx != len(img.Children)-1 {
|
||||||
_, _ = fmt.Fprint(out, headers[0].Print(clr, "├─ "+sub.Platform))
|
_, _ = fmt.Fprint(out, headers[0].Print(clr, "├─ "+text))
|
||||||
} else {
|
} else {
|
||||||
_, _ = fmt.Fprint(out, headers[0].Print(clr, "└─ "+sub.Platform))
|
_, _ = fmt.Fprint(out, headers[0].Print(clr, "└─ "+text))
|
||||||
}
|
}
|
||||||
|
|
||||||
printDetails(out, headers, clr, sub.Details)
|
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 {
|
if len(img.Names) == 0 {
|
||||||
_, _ = fmt.Fprint(out, headers[0].Print(untaggedColor, "<untagged>"))
|
_, _ = 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.
|
// name will be printed alongside other columns.
|
||||||
if nameIdx < len(img.Names)-1 {
|
if nameIdx < len(img.Names)-1 {
|
||||||
_, fullWidth := out.GetTtySize()
|
_, fullWidth := out.GetTtySize()
|
||||||
_, _ = fmt.Fprintln(out, color.Apply(truncateRunes(name, int(fullWidth))))
|
_, _ = fmt.Fprintln(out, color.Apply(tui.Ellipsis(name, int(fullWidth))))
|
||||||
} else {
|
} else {
|
||||||
_, _ = fmt.Fprint(out, headers[0].Print(color, name))
|
_, _ = fmt.Fprint(out, headers[0].Print(color, name))
|
||||||
}
|
}
|
||||||
@ -318,14 +412,6 @@ type imgColumn struct {
|
|||||||
Color *aec.ANSI
|
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 {
|
func (h imgColumn) Print(clr aec.ANSI, s string) string {
|
||||||
switch h.Align {
|
switch h.Align {
|
||||||
case alignCenter:
|
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 {
|
func (h imgColumn) PrintC(clr aec.ANSI, s string) string {
|
||||||
ln := utf8.RuneCountInString(s)
|
ln := tui.Width(s)
|
||||||
|
|
||||||
if ln > h.Width {
|
if ln > h.Width {
|
||||||
return clr.Apply(truncateRunes(s, h.Width))
|
return clr.Apply(tui.Ellipsis(s, h.Width))
|
||||||
}
|
}
|
||||||
|
|
||||||
fill := h.Width - ln
|
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 {
|
func (h imgColumn) PrintL(clr aec.ANSI, s string) string {
|
||||||
ln := utf8.RuneCountInString(s)
|
ln := tui.Width(s)
|
||||||
if ln > h.Width {
|
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)
|
return clr.Apply(s) + strings.Repeat(" ", h.Width-ln)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h imgColumn) PrintR(clr aec.ANSI, s string) string {
|
func (h imgColumn) PrintR(clr aec.ANSI, s string) string {
|
||||||
ln := utf8.RuneCountInString(s)
|
ln := tui.Width(s)
|
||||||
if ln > h.Width {
|
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)
|
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.
|
// widestFirstColumnValue calculates the width needed to fully display the image names and platforms.
|
||||||
func widestFirstColumnValue(headers []imgColumn, images []topImage) int {
|
func widestFirstColumnValue(headers []imgColumn, images []topImage) int {
|
||||||
width := len(headers[0].Title)
|
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