diff --git a/cli/dump.go b/cli/dump.go index e603b43..4ea0740 100644 --- a/cli/dump.go +++ b/cli/dump.go @@ -26,6 +26,14 @@ var dumpUICmd = &cobra.Command{ } response := commands.DumpUICommand(req) + + // Printing text through the JSON envelope would escape every newline, + // which defeats the point of the format. + if dumpResponse, ok := response.Data.(commands.DumpUIResponse); ok && dumpResponse.Text != "" { + fmt.Print(dumpResponse.Text) + return nil + } + printJson(response) if response.Status == "error" { return fmt.Errorf("%s", response.Error) @@ -43,5 +51,5 @@ func init() { // dump ui command flags dumpUICmd.Flags().StringVar(&deviceId, "device", "", "ID of the device to dump UI tree from") - dumpUICmd.Flags().StringVar(&dumpUIFormat, "format", "", "Output format: 'raw' for unprocessed tree from agent (Default: json)") + dumpUICmd.Flags().StringVar(&dumpUIFormat, "format", "", "Output format: 'text' for indented element lines, 'raw' for unprocessed tree from agent (Default: json)") } diff --git a/commands/dump.go b/commands/dump.go index 2c3c62d..bb5cecc 100644 --- a/commands/dump.go +++ b/commands/dump.go @@ -6,7 +6,9 @@ import ( "github.com/mobile-next/mobilecli/types" ) -// DumpUIRequest represents the parameters for dumping UI tree +// DumpUIRequest represents the parameters for dumping UI tree. +// Format is "json" (default), "text" for indented one-line-per-element output, +// or "raw" for the unprocessed agent tree. type DumpUIRequest struct { DeviceID string `json:"deviceId"` Format string `json:"format"` @@ -16,6 +18,7 @@ type DumpUIRequest struct { type DumpUIResponse struct { Elements []devices.ScreenElement `json:"elements,omitempty"` RawData any `json:"rawData,omitempty"` + Text string `json:"text,omitempty"` } // DumpUICommand starts an agent and dumps the UI tree from the specified device @@ -54,8 +57,14 @@ func DumpUICommand(req DumpUIRequest) *CommandResponse { } types.AttachRefs(elements) - response = DumpUIResponse{ - Elements: elements, + if req.Format == "text" { + response = DumpUIResponse{ + Text: types.FormatText(elements), + } + } else { + response = DumpUIResponse{ + Elements: elements, + } } } diff --git a/types/screen_text.go b/types/screen_text.go new file mode 100644 index 0000000..286dd8b --- /dev/null +++ b/types/screen_text.go @@ -0,0 +1,76 @@ +package types + +import ( + "fmt" + "strings" +) + +// FormatText renders a ref-annotated tree as indented lines, one element per +// line, e.g. ` @e7 [Button] "Add" #add-button (disabled)`. It is far more +// compact than JSON, which matters when the tree is fed to a model. +// Call AttachRefs first, otherwise refs render empty. +func FormatText(elements []ScreenElement) string { + var builder strings.Builder + writeElementLines(&builder, elements, 0) + return builder.String() +} + +func writeElementLines(builder *strings.Builder, elements []ScreenElement, depth int) { + for _, element := range elements { + builder.WriteString(strings.Repeat(" ", depth)) + builder.WriteString(formatElementLine(element)) + builder.WriteString("\n") + writeElementLines(builder, element.Children, depth+1) + } +} + +func formatElementLine(element ScreenElement) string { + parts := []string{fmt.Sprintf("@%s [%s]", element.Ref, element.Type)} + + if description := elementDescription(element); description != "" { + parts = append(parts, fmt.Sprintf("%q", description)) + } + + // The identifier is what a caller would write a selector against, so it stays + // on the line even when a label is already shown. + if element.Identifier != nil && *element.Identifier != "" { + parts = append(parts, "#"+*element.Identifier) + } + + if states := elementStates(element); states != "" { + parts = append(parts, states) + } + + return strings.Join(parts, " ") +} + +// elementDescription is the first non-empty human-readable field, in the order +// an agent would care about. +func elementDescription(element ScreenElement) string { + for _, candidate := range []*string{element.Text, element.Label, element.Name, element.Value, element.Placeholder} { + if candidate != nil && *candidate != "" { + return *candidate + } + } + return "" +} + +func elementStates(element ScreenElement) string { + var states []string + if element.Enabled != nil && !*element.Enabled { + states = append(states, "disabled") + } + if element.Checked != nil && *element.Checked { + states = append(states, "checked") + } + if element.Selected != nil && *element.Selected { + states = append(states, "selected") + } + if element.Focused != nil && *element.Focused { + states = append(states, "focused") + } + if len(states) == 0 { + return "" + } + return "(" + strings.Join(states, ", ") + ")" +} diff --git a/types/screen_text_test.go b/types/screen_text_test.go new file mode 100644 index 0000000..4e73038 --- /dev/null +++ b/types/screen_text_test.go @@ -0,0 +1,54 @@ +package types + +import "testing" + +func stringPtr(value string) *string { + return &value +} + +func boolPtr(value bool) *bool { + return &value +} + +func TestFormatTextIndentsTreeAndShowsRefsLabelsAndStates(t *testing.T) { + elements := []ScreenElement{ + { + Type: "Window", + Children: []ScreenElement{ + { + Type: "Button", + Label: stringPtr("Add"), + Identifier: stringPtr("add-button"), + Enabled: boolPtr(false), + }, + { + Type: "TextField", + Placeholder: stringPtr("First name"), + Focused: boolPtr(true), + }, + }, + }, + } + AttachRefs(elements) + + got := FormatText(elements) + want := "@e1 [Window]\n" + + " @e2 [Button] \"Add\" #add-button (disabled)\n" + + " @e3 [TextField] \"First name\" (focused)\n" + + if got != want { + t.Fatalf("got:\n%s\nwant:\n%s", got, want) + } +} + +func TestFormatTextPrefersTextOverOtherLabels(t *testing.T) { + elements := []ScreenElement{ + {Type: "StaticText", Text: stringPtr("Hello"), Label: stringPtr("ignored")}, + } + AttachRefs(elements) + + want := "@e1 [StaticText] \"Hello\"\n" + if got := FormatText(elements); got != want { + t.Fatalf("got %q, want %q", got, want) + } +}