diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1271b8d..8b5ef3c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -88,7 +88,7 @@ jobs: env: STUDIO_PIECES: ${{ github.workspace }}/build/pieces run: | - & $env:GODOT --headless --path . --script res://tests/run.gd -- contract studio pieces connect recolour shared_pieces mcp + & $env:GODOT --headless --path . --script res://tests/run.gd -- contract studio pieces connect recolour shared_pieces mcp ui if ($LASTEXITCODE -ne 0) { throw 'A suite failed.' } - name: Downloads and window cancellation with Windows curl timeout-minutes: 3 @@ -267,7 +267,7 @@ jobs: env: STUDIO_PIECES: ${{ github.workspace }}/build/pieces run: | - & $env:GODOT --headless --path . --script res://tests/run.gd -- contract studio pieces connect recolour shared_pieces mcp + & $env:GODOT --headless --path . --script res://tests/run.gd -- contract studio pieces connect recolour shared_pieces mcp ui if ($LASTEXITCODE -ne 0) { throw 'A suite failed.' } - name: Downloads and window cancellation with Godot's HTTP client timeout-minutes: 3 diff --git a/src/app/main.gd b/src/app/main.gd index e48ca2c..4ceeca8 100644 --- a/src/app/main.gd +++ b/src/app/main.gd @@ -33,6 +33,8 @@ var _section: Texture2D var _orbiting := false var _closing := false var _window_pids := [] +var _empty_preview: CenterContainer +var _preview_hint: PanelContainer ## What closing the window ends with; tests name another. var on_quit := func(): get_tree().quit() @@ -143,6 +145,7 @@ func _open() -> void: root.set_anchors_preset(Control.PRESET_FULL_RECT) root.mouse_filter = Control.MOUSE_FILTER_IGNORE layer.add_child(root) + _preview_guidance() home = HomePanel.new() root.add_child(home) panel = StudioPanel.new() @@ -200,13 +203,54 @@ func _layout() -> void: var view := root.size if view.x <= 0.0: return - var width := minf(PANEL_WIDTH, view.x * 0.45) if view.x >= 1000.0 else minf(PANEL_WIDTH * 1.4, view.x - GAP * 2.0) + var width := minf(PANEL_WIDTH, view.x - GAP * 2.0) for side: Control in [home, panel]: side.set_anchors_preset(Control.PRESET_TOP_LEFT) side.offset_left = GAP side.offset_top = GAP side.offset_right = GAP + width side.offset_bottom = maxf(view.y - GAP, GAP) + _empty_preview.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + _empty_preview.offset_left = width + GAP * 2.0 + _empty_preview.offset_right = -GAP + _empty_preview.offset_top = GAP + _empty_preview.offset_bottom = -GAP + _preview_hint.size = _preview_hint.get_combined_minimum_size() + _preview_hint.position = Vector2((width + GAP + view.x - _preview_hint.size.x) / 2.0, GAP) + var dialog := layer.get_node_or_null("ConnectAgentDialog") as AcceptDialog + if dialog: + UiSkin.refit_dialog.call_deferred(dialog) + +func _preview_guidance() -> void: + _empty_preview = CenterContainer.new() + _empty_preview.mouse_filter = Control.MOUSE_FILTER_IGNORE + root.add_child(_empty_preview) + var card := PanelContainer.new() + card.theme_type_variation = "Inset" + card.mouse_filter = Control.MOUSE_FILTER_IGNORE + card.custom_minimum_size.x = 320 + _empty_preview.add_child(card) + var text := VBoxContainer.new() + text.add_theme_constant_override("separation", 8) + text.mouse_filter = Control.MOUSE_FILTER_IGNORE + card.add_child(text) + for entry in [["Your preview appears here", "Heading"], ["Bring a model or recolor a look\nto see it on the stage.", "Muted"]]: + var line := Label.new() + line.text = entry[0] + line.theme_type_variation = entry[1] + line.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + line.mouse_filter = Control.MOUSE_FILTER_IGNORE + text.add_child(line) + _preview_hint = PanelContainer.new() + _preview_hint.theme_type_variation = "Pill" + _preview_hint.mouse_filter = Control.MOUSE_FILTER_IGNORE + _preview_hint.hide() + root.add_child(_preview_hint) + var hint := Label.new() + hint.text = "Drag the preview to rotate" + hint.theme_type_variation = "Muted" + hint.mouse_filter = Control.MOUSE_FILTER_IGNORE + _preview_hint.add_child(hint) func _show_home() -> void: if home == null or flow != null: @@ -323,6 +367,9 @@ func open_window() -> bool: func _process(_delta: float) -> void: _window_pids = _window_pids.filter(func(pid): return OS.get_process_exit_code(pid) == -1) + if stage != null: + _empty_preview.visible = stage.models.is_empty() + _preview_hint.visible = not stage.models.is_empty() static func window_command(exe: String, args: Array, windows := OS.get_name() == "Windows") -> Array: if windows: @@ -367,22 +414,23 @@ func _connect_dialog(exe := OS.get_executable_path()) -> void: ask.ok_button_text = "Done" ask.theme = skin.theme var box := VBoxContainer.new() - box.add_theme_constant_override("separation", 6) + box.custom_minimum_size.x = CONNECT_WIDTH + box.size_flags_horizontal = Control.SIZE_EXPAND_FILL + box.add_theme_constant_override("separation", 12) ask.add_child(box) + var title := Label.new() + title.text = "Connect an agent" + title.theme_type_variation = "Heading" + box.add_child(title) var intro := Label.new() intro.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART intro.text = "Run one of these once in %s. Afterwards your agent can make characters and companions with Studio, with no window. If you move this app to another folder, run the command again." % ("Terminal" if OS.get_name() == "macOS" else "PowerShell") - intro.custom_minimum_size = Vector2(CONNECT_WIDTH, 0) box.add_child(intro) - var scroll := ScrollContainer.new() - scroll.name = "Commands" - scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED - scroll.custom_minimum_size = Vector2(CONNECT_WIDTH + 12, minf(420.0, get_viewport().get_visible_rect().size.y - 220.0)) - box.add_child(scroll) var list := VBoxContainer.new() + list.name = "Commands" list.add_theme_constant_override("separation", 6) list.size_flags_horizontal = Control.SIZE_EXPAND_FILL - scroll.add_child(list) + box.add_child(list) var before := [] if OS.has_feature("template") else ["--path", ProjectSettings.globalize_path("res://").trim_suffix("/")] for entry in connect_commands(exe, before): var heading := Label.new() @@ -390,6 +438,7 @@ func _connect_dialog(exe := OS.get_executable_path()) -> void: heading.theme_type_variation = "Section" list.add_child(heading) var row := HBoxContainer.new() + row.add_theme_constant_override("separation", 8) list.add_child(row) var text := TextEdit.new() text.text = entry[1] @@ -400,6 +449,8 @@ func _connect_dialog(exe := OS.get_executable_path()) -> void: row.add_child(text) var copy := Button.new() copy.text = "Copy" + copy.custom_minimum_size.x = 80 + copy.size_flags_vertical = Control.SIZE_SHRINK_BEGIN copy.tooltip_text = "Copy the %s command" % entry[0] var command: String = entry[1] copy.pressed.connect(func(): @@ -410,9 +461,9 @@ func _connect_dialog(exe := OS.get_executable_path()) -> void: docker.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART docker.theme_type_variation = "Muted" docker.text = "Docker Agent: add the toolset to your agent in its YAML file, with root replaced by your agent's name." - docker.custom_minimum_size = Vector2(CONNECT_WIDTH, 0) box.add_child(docker) layer.add_child(ask) + UiSkin.frame_dialog(ask, title) ask.confirmed.connect(ask.queue_free) ask.canceled.connect(ask.queue_free) - ask.popup_centered() + UiSkin.popup_dialog(ask, int(CONNECT_WIDTH + ask.get_theme_stylebox("panel").get_minimum_size().x)) diff --git a/src/ui/home_panel.gd b/src/ui/home_panel.gd index 5fd001f..f8dc4cd 100644 --- a/src/ui/home_panel.gd +++ b/src/ui/home_panel.gd @@ -64,6 +64,7 @@ func _build() -> void: _note("Make characters and companions for Agent Guild from a model you have, or by recoloring a world's own.") var label := Label.new() label.text = "World" + label.theme_type_variation = "Field" rows.add_child(label) var pick := OptionButton.new() for id in WORLD_ORDER: @@ -95,20 +96,39 @@ func _build() -> void: if pieces.is_empty(): _note("Pieces you save are listed here, to edit later.") for piece: Dictionary in pieces: - var line := HBoxContainer.new() - rows.add_child(line) + var card := PanelContainer.new() + card.theme_type_variation = "Inset" + rows.add_child(card) + var entry := VBoxContainer.new() + entry.add_theme_constant_override("separation", 6) + card.add_child(entry) var name := Label.new() - name.text = "%s · %s" % [piece.title, piece.slot] - name.size_flags_horizontal = Control.SIZE_EXPAND_FILL - name.clip_text = true - name.tooltip_text = String(piece.get("saved", "")) - line.add_child(name) + name.text = piece.title + name.theme_type_variation = "Strong" + name.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS + name.tooltip_text = "%s\n%s" % [piece.title, piece.get("saved", "")] + entry.add_child(name) + var slot := Label.new() + slot.text = piece.slot + slot.theme_type_variation = "Muted" + slot.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + slot.tooltip_text = piece.slot + entry.add_child(slot) + var line := HBoxContainer.new() + line.add_theme_constant_override("separation", 8) + entry.add_child(line) var id: String = piece.id var edit := _button("Edit", func(): edit_requested.emit(id), line) edit.disabled = art == "downloading" or piece.error != "" - _button("Remove", func(): remove_requested.emit(id), line) + var remove := _button("Remove", func(): remove_requested.emit(id), line) + remove.theme_type_variation = "Quiet" + remove.tooltip_text = "Remove from this list. The saved folder is kept." if piece.error != "": - _note("It can't be edited: %s." % piece.error, "Note") + var problem := Label.new() + problem.text = "It can't be edited: %s." % piece.error + problem.theme_type_variation = "Note" + problem.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + entry.add_child(problem) _heading("Agents", false) _note("An agent can make pieces with Studio for you.") _button("Connect an agent…", func(): connect_requested.emit()) diff --git a/src/ui/studio_panel.gd b/src/ui/studio_panel.gd index 2474874..04b640f 100644 --- a/src/ui/studio_panel.gd +++ b/src/ui/studio_panel.gd @@ -344,6 +344,7 @@ func _colour_controls(rebuild: Callable) -> void: else: var choose := Label.new() choose.text = "Color to change" + choose.theme_type_variation = "Field" rows.add_child(choose) _swatches = HFlowContainer.new() _swatches.add_theme_constant_override("h_separation", 8) @@ -352,6 +353,7 @@ func _colour_controls(rebuild: Callable) -> void: var row := HBoxContainer.new() var into := Label.new() into.text = "Change it to" + into.theme_type_variation = "Strong" into.size_flags_horizontal = Control.SIZE_EXPAND_FILL row.add_child(into) _to = _picker(tints.to if tints.to is Color else Color.WHITE, func(c): @@ -559,6 +561,7 @@ func _show_preview(kind: String, notes: Array) -> void: func _adjust(kind: String) -> void: var label := Label.new() label.text = "Adjust" + label.theme_type_variation = "Field" rows.add_child(label) if kind == "character": _slider("Height", 0.85, 1.15, float(options.get("height_share", 1.0)), 0.01, func(v): _set_option("height_share", v), @@ -585,6 +588,7 @@ func _show_finish() -> void: _editing_line() var label := Label.new() label.text = "Title" + label.theme_type_variation = "Field" rows.add_child(label) var title := LineEdit.new() title.text = editing.title if not editing.is_empty() else (String(files[0]).get_file().get_basename() if recolour.is_empty() and not files.is_empty() else "%s recolored" % where.name).capitalize() @@ -717,12 +721,15 @@ func _action(text: String, busy: String, pressed: Callable, into: Container = nu return b func _choice(label_text: String, items: Array, current: String, picked: Callable) -> void: - var row := HBoxContainer.new() + var row := VBoxContainer.new() + row.add_theme_constant_override("separation", 6) var label := Label.new() label.text = label_text - label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + label.theme_type_variation = "Field" row.add_child(label) var pick := OptionButton.new() + pick.size_flags_horizontal = Control.SIZE_EXPAND_FILL + pick.clip_text = true for item in items: pick.add_item(item[0]) pick.select(maxi(0, items.map(func(i): return i[1]).find(current))) @@ -757,20 +764,27 @@ func _slider(label_text: String, low: float, high: float, value: float, step_by: var row := HBoxContainer.new() var label := Label.new() label.text = label_text - label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + label.theme_type_variation = "Strong" row.add_child(label) var amount := Label.new() amount.theme_type_variation = "Muted" + amount.size_flags_horizontal = Control.SIZE_EXPAND_FILL + amount.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT + amount.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + amount.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS amount.text = say.call(value) + amount.tooltip_text = amount.text row.add_child(amount) var slider := HSlider.new() slider.min_value = low slider.max_value = high slider.step = step_by slider.value = value + slider.custom_minimum_size.y = 28 slider.size_flags_horizontal = Control.SIZE_EXPAND_FILL slider.value_changed.connect(func(v): amount.text = say.call(v) + amount.tooltip_text = amount.text changed.call(v)) var box := VBoxContainer.new() box.add_theme_constant_override("separation", 2) diff --git a/src/ui/ui_skin.gd b/src/ui/ui_skin.gd index 3ab5587..85c22bc 100644 --- a/src/ui/ui_skin.gd +++ b/src/ui/ui_skin.gd @@ -494,8 +494,11 @@ func _buttons() -> void: # Choice: one of a set, filled in the accent while chosen. t.set_type_variation("Choice", "Button") - t.set_stylebox("pressed", "Choice", primary_down) - t.set_stylebox("hover_pressed", "Choice", primary) + var choices := {"normal": normal, "hover": hover, "pressed": primary_down, "hover_pressed": primary, "disabled": disabled} + for state in choices: + var choice: StyleBoxFlat = choices[state].duplicate() + _margins(choice, 10, 8, 10, 8) + t.set_stylebox(state, "Choice", choice) for c in ["font_pressed_color", "font_hover_pressed_color"]: t.set_color(c, "Choice", color("accent-text")) @@ -582,6 +585,7 @@ func _fields() -> void: t.set_stylebox("focus", type, focused) t.set_stylebox("read_only", type, field) t.set_color("font_color", type, color("text")) + t.set_color("font_readonly_color" if type == "TextEdit" else "font_uneditable_color", type, color("text")) t.set_color("font_placeholder_color", type, color("muted", 0.85)) t.set_color("caret_color", type, color("accent")) t.set_color("selection_color", type, color("accent", 0.3)) @@ -590,6 +594,23 @@ func _fields() -> void: lane.border_color = color("border") t.set_stylebox("background", "ProgressBar", lane) t.set_stylebox("fill", "ProgressBar", _flat(color("accent"), r)) + var track := _flat(color("border"), 2) + track.content_margin_top = 2 + track.content_margin_bottom = 2 + t.set_stylebox("slider", "HSlider", track) + var filled := track.duplicate() + filled.bg_color = color("accent") + t.set_stylebox("grabber_area", "HSlider", filled) + var active := filled.duplicate() + active.bg_color = color("focus") + t.set_stylebox("grabber_area_highlight", "HSlider", active) + var knobs := {"grabber": "accent", "grabber_highlight": "focus", "grabber_disabled": "muted"} + for state in knobs: + var c := color(knobs[state]) + t.set_icon(state, "HSlider", DPITexture.create_from_string("" % [c.to_html(false), color("surface").to_html(false)])) + var slider_focus := focused.duplicate() + slider_focus.draw_center = false + t.set_stylebox("focus", "HSlider", slider_focus) t.set_color("font_color", "LinkButton", color("accent")) t.set_color("font_hover_color", "LinkButton", color("gold-ink")) diff --git a/tests/ui_test.gd b/tests/ui_test.gd new file mode 100644 index 0000000..9184dd7 --- /dev/null +++ b/tests/ui_test.gd @@ -0,0 +1,100 @@ +extends RefCounted + +var failures := [] + +func check(ok: bool, what: String) -> void: + if not ok: + failures.append(what) + +func run(tree: SceneTree) -> Array: + var previous_size := tree.root.size + var app: Node = load("res://src/app/main.gd").new() + tree.root.add_child(app) + await _layout(tree) + for world in YardModel.WORLDS: + for mode in ["light", "dark"]: + app.theme = mode + app._set_world(world) + app._connect_dialog("C:/Apps $x/it's/AgentGuildStudio.exe") + await _layout(tree) + var dialog: AcceptDialog = app.layer.get_node("ConnectAgentDialog") + var fields := dialog.find_children("*", "TextEdit", true, false) + var before := [] if OS.has_feature("template") else ["--path", ProjectSettings.globalize_path("res://").trim_suffix("/")] + var commands: Array = app.connect_commands("C:/Apps $x/it's/AgentGuildStudio.exe", before) + check(fields.size() == commands.size(), "%s/%s: the dialog lost a command" % [world, mode]) + for i in mini(fields.size(), commands.size()): + var field: TextEdit = fields[i] + check(field.text == commands[i][1] and not field.editable, "%s/%s: a displayed command changed" % [world, mode]) + var background: Color = app.skin.color("surface").blend(field.get_theme_stylebox("read_only").bg_color) + var foreground := background.blend(field.get_theme_color("font_readonly_color")) + check(_contrast(foreground, background) >= 4.5, "%s/%s: read-only command text lacks contrast" % [world, mode]) + for size in [Vector2i(1600, 900), Vector2i(1280, 800), Vector2i(960, 600), Vector2i(1000, 600)]: + tree.root.size = size + await _layout(tree) + check(app.home.size.x <= 400.1 and app.panel.size.x <= 400.1, "%s/%s at %s: the sidebar expands over the preview" % [world, mode, size]) + check(Rect2(Vector2.ZERO, Vector2(size)).encloses(Rect2(Vector2(dialog.position), Vector2(dialog.size))), "%s/%s at %s: the connection dialog extends outside the window" % [world, mode, size]) + var copies := dialog.find_children("*", "Button", true, false).filter(func(b): return b.text == "Copy") + check(copies.size() == commands.size(), "a connection command lost its Copy button") + if not copies.is_empty(): + copies.back().grab_focus() + await _layout(tree) + var scroll: ScrollContainer = dialog.get_meta("scroll") + check(scroll.get_global_rect().grow(1.0).encloses(copies.back().get_global_rect()), "%s/%s: the final Copy button %s is outside the scroll area %s" % [world, mode, copies.back().get_global_rect(), scroll.get_global_rect()]) + check(copies.all(func(b): return b.size.y < 60), "a Copy button stretches to the height of its command") + dialog.queue_free() + await _layout(tree) + app.theme = "light" + app._set_world("guild") + tree.root.size = Vector2i(960, 600) + var title := "Moonlit Clockwork Guardian with a deliberately long name for the character library" + var slot := "The Guild Yard, Antigravity CLI crew" + app.home.show_home("guild", "ready", 0, "", [{"id": "ui-example", "title": title, "slot": slot, "error": "", "saved": "C:/Characters/Guardian"}]) + await _layout(tree) + var labels: Array = app.home.find_children("*", "Label", true, false) + var names: Array = labels.filter(func(label): return label.text == title) + var slots: Array = labels.filter(func(label): return label.text == slot) + check(names.size() == 1 and slots.size() == 1, "the library does not show the title and slot separately") + if names.size() == 1 and slots.size() == 1: + check(title in names[0].tooltip_text and "C:/Characters/Guardian" in names[0].tooltip_text, "a long title or saved path is inaccessible") + check(names[0].get_global_rect().end.x <= app.home.get_global_rect().end.x and slots[0].get_global_rect().end.x <= app.home.get_global_rect().end.x, "a library entry overflows the sidebar") + check(app._empty_preview.visible and not app._preview_hint.visible, "the empty workspace has no guidance") + for overlay in [app._empty_preview, app._preview_hint]: + var controls: Array = [overlay] + overlay.find_children("*", "Control", true, false) + check(controls.all(func(c): return c.mouse_filter == Control.MOUSE_FILTER_IGNORE), "preview guidance intercepts camera input") + var model := Node3D.new() + app.stage.models.append(model) + await _layout(tree) + check(not app._empty_preview.visible and app._preview_hint.visible, "preview guidance does not follow the model's visibility") + app.stage.models.clear() + model.free() + app.home.hide() + app.panel.open("guild", app.TOOLS) + app.panel.show() + app.panel.where = app.panel.slots[2] + app.panel.show_preview("character", [], "work") + await _layout(tree) + var buttons: Array = app.panel.rows.find_children("*", "Button", true, false) + var motions: Array = buttons.filter(func(b): return b.text in ["Walk", "Work", "Pause", "Rest", "Done"]) + check(motions.size() == 5, "a character preview motion is missing") + check(motions.all(func(b): return is_equal_approx(b.global_position.y, motions[0].global_position.y)), "character motion choices wrap unnecessarily") + var slider: HSlider = app.panel.rows.find_children("*", "HSlider", true, false).front() + var values := [] + slider.value_changed.connect(func(value): values.append(value)) + slider.value = 1.1 + check(values.size() == 1 and is_equal_approx(app.panel.options.get("height_share", 0.0), 1.1), "the styled height slider changed how its value is delivered") + app.free() + tree.auto_accept_quit = true + tree.root.size = previous_size + await _layout(tree) + return failures + +func _layout(tree: SceneTree) -> void: + for i in 8: + await tree.process_frame + +func _contrast(a: Color, b: Color) -> float: + var first := a.srgb_to_linear() + var second := b.srgb_to_linear() + var x := first.r * 0.2126 + first.g * 0.7152 + first.b * 0.0722 + var y := second.r * 0.2126 + second.g * 0.7152 + second.b * 0.0722 + return (maxf(x, y) + 0.05) / (minf(x, y) + 0.05) diff --git a/tests/ui_test.gd.uid b/tests/ui_test.gd.uid new file mode 100644 index 0000000..918944c --- /dev/null +++ b/tests/ui_test.gd.uid @@ -0,0 +1 @@ +uid://bkv2h824ldflf