Split InspectorPanel into sections so it can host controls #152
Labels
No labels
assets
bug
duplicate
enhancement
help wanted
invalid
question
testing
ui
wontfix
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Blocks
#136 Worker slot control on a selected construction site
liamjd/UntitledColonyBuilder
Reference
liamjd/UntitledColonyBuilder#152
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Part of milestone #6 (Selection and inspection). Follows #142. Blocks #136.
The inspector from #142 turns
Dictionary[String, String]rows into Labels, and rebuilds every Label on each tick. That works for read-only text, but it cannot host controls. A button rebuilt ten times a second loses its hover and pressed state, and a renameLineEditwould lose what is being typed.Controls are coming, starting with #136's worker slots and later things like wages, renaming a pawn, or pausing construction. Nobody knows yet exactly which. So rather than make the rows describe widgets, split the panel into sections: small self-contained scenes, stacked in the panel. Each decides whether it applies to the selected entity and updates itself in place. A new control is then a new section, not a change to the panel.
This is a refactor. The panel should show exactly what it shows today, using one section.
Rough shape
These are starting points, not a spec. Liam is working out the detail.
InspectorSection(a Control) that answersapplies_to(entity)andrefresh(entity).selection_changedandGameClock.tickas now, shows or hides sections, and refreshes the visible ones. Sections do not subscribe to those signals themselves.title_for/pawn_rows/building_rows.game.gd, much asTaskContextdoes for tasks.TaskManager.set_open_slots()), never by writing records directly.Acceptance criteria
tools/screenshot_inspector.gd.%Rows, the row builders and their constants (now onDetailsSection), andpanel.tasks/panel.buildings(nowpanel.context). See Decisions.Decisions (2026-10-11)
pawn_rows(),building_rows(),IDLE_TEXTandNONE_TEXTmove toDetailsSection, so the panel no longer knows what a pawn row is.title_for()stays on the panel, because the title belongs to the panel. The #142 builder tests changeInspectorPanel.toDetailsSection.and nothing else.panel.context: InspectorContextholdstasksandbuildings, andtask_for()moves onto it from the panel.game.gdbuilds it the way it buildsTaskContext. There are no forwardingpanel.tasks/panel.buildingsproperties, so there is only one way to inject.Out of scope
Spec (drafted with Claude)
The tests are on branch
152-inspector-panel-sections:test/unit/test_inspector_sections.gd(new),test/unit/test_inspector_panel.gd(renames only), andtest/support/stub_inspector_section.gd(new). They and this outline are one spec.2.1 Files
ui/inspector/inspector_section.gd@abstract class_name InspectorSection extends VBoxContainer. The contract:applies_to()andrefresh().ui/inspector/inspector_context.gdclass_name InspectorContext extends RefCounted:tasks,buildings,task_for().ui/inspector/sections/details_section.gdclass_name DetailsSection extends InspectorSection. Today's rows:pawn_rows,building_rows,IDLE_TEXT,NONE_TEXT,_renderall move here unchanged.ui/inspector/sections/details_section.tscnDetailsSectionplus%Rows. See 2.6.ui/inspector/inspector_panel.gdtasks,buildings, the row builders and their constants,_task_for,_renderandrows_grid. Gainscontext,sections_boxandsections().refresh()drives the sections.ui/inspector/inspector_panel.tscn%Rows(GridContainer) is replaced by%Sections(VBoxContainer) holding one%DetailsSectioninstance.common/game.gdInspectorContextand assigns it toinspector_panel.context, replacing the two field assignments.tools/screenshot_inspector.gd2.2 API: full signatures
InspectorSectionextends VBoxContainer, not plainControl. A plainControltakes no minimum size from its children, so a section would collapse to zero height in the panel's VBox. A VBoxContainer stacks whatever the section holds. A section that wants a row of buttons (#136) puts an HBoxContainer inside it.InspectorSectionis@abstract, likeEntityList, so nothing can instantiate it directly. Concrete sections areNodes, so testsautofreethem or free them with the panel.InspectorContextisRefCountedand is never freed.Error paths: none. There is no
push_errorand no error return anywhere in this change, and no test matches on error text. A null registry and a null entity both resolve to a null task.panel.contextis never set to null:game.gdreplaces it, and nothing else assigns it.2.3 Each method's job, in one line
New and changed:
InspectorSection.applies_to(entity, ctx): whether this section has anything to show forentity. The panel asks again on every refresh, so the answer can change while the same entity stays selected.InspectorSection.refresh(entity, ctx): brings the section's nodes up to date forentity. It is only called while the section applies, andentityis never null. A section with controls updates its nodes in place here and does not rebuild them.InspectorContext.task_for(entity): the body of today'sInspectorPanel._task_for(), moved. For aPawn:tasks.get_task(pawn.current_task_id)whentasks != null, else null. For aBuilding:buildings.task_for(building.id)whenbuildings != null, else null. Anything else, including null: null.DetailsSection.applies_to(entity, _ctx):entity is Pawn or entity is Building.DetailsSection.refresh(entity, ctx): for a pawn,_render(pawn_rows(entity as Pawn, ctx.task_for(entity))), and the same withbuilding_rowsfor a building.DetailsSection.pawn_rows/building_rows/_render: unchanged from #142._renderstill frees and rebuilds the Labels. That is fine for read-only text, and it only touches this section's own%Rows.InspectorPanel.sections(): the children ofsections_boxthat areInspectorSection, in tree order. Other children, such as a separator, are skipped. It reads the tree on every call and never caches in_ready(), so a section added later is picked up.InspectorPanel.refresh(): see 2.5.Existing methods the new code relies on:
GameState.selected(setter): ignores a no-op assignment. Otherwise it accepts onlynull, aPawnor aBuilding, and emitsselection_changed(new, previous)itself. Anything else is apush_errorand is not stored. So the panel never sees another entity type selected.GameState._clear_if_selected(entity): connected topawn_removed/building_removed, and setsselected = null, which emitsselection_changed. This is how removal hides the panel. The panel has no handler of its own for removal.TaskManager.get_task(task_id) -> Task: returns null for an unknown id. Never errors.Buildings.task_for(building_id) -> Task: returns null whentasks == null. Otherwise it scanstasks.all()for aConstructionTaskorDeconstructionTaskwhosebuilding_idmatches. Returns null if none matches.2.4 Signals
No new signals, and no signal changes. Sections connect to nothing: the panel holds all the timing.
selection_changed(new: Entity, previous: Entity)GameStateGameState.selectedsetterInspectorPanel._on_selection_changed()InspectorPanel._ready()(unchanged)tick(run_tick: int)GameClockGameClockInspectorPanel._on_tick()InspectorPanel._ready()(unchanged)2.5 Wiring pattern
Step 2 keeps a section from ever being handed null, so no section has to guard against it. Step 5a runs on every refresh, ticks included. That is how #136's slot control disappears when the site's task ends while the same building stays selected.
_ready()still callsrefresh()once, so a panel made over an existing selection starts correct. The sections' own@onreadyvars are set by then, because children are readied before their parent.game.gd, replacing lines 72-73 (still inside thenot Engine.is_editor_hint()guard):GDScript rules this relies on:
title_for,pawn_rowsandbuilding_rowsstay static and only return values.task_forandapplies_toread and never change anything.applies_tois a method call, not a signal.TaskManager.set_open_slots()in #136), never by writing to records directly. Nothing in this ticket writes at all.2.6 Scene structure
%Rowsfrom the panel scene and add%Sectionsas a new node, rather than retyping it, so the editor gives it a freshunique_id. Keep theuid=andunique_id=values of every other node.%DetailsSectionhas to be marked unique on the instance in the panel scene ("Access as Unique Name" on the instanced node). The tests reach it withpanel.get_node("%DetailsSection").%Rowsis unique insidedetails_section.tscnonly, which is what the section's own@onreadyuses.game.tscn. "Adding a section" therefore means adding an instance under%Sectionsininspector_panel.tscn, which changes the scene but notinspector_panel.gd.EntityList, nothing inherits a layout: each section scene has its own concrete script on its root.ui/inspector/sections/is a new folder for section scenes, so #136's slot section sits beside this one.Names the tests depend on:
%Sections(throughpanel.sections_box),%DetailsSection,%TitleLbl(throughpanel.title_lbl), and%Rows(throughDetailsSection.rows_grid). Renaming any of them breaks the tests.2.7 Tests
test/unit/test_inspector_panel.gd(#142's tests). What they check is unchanged; only the paths moved:InspectorPanel.pawn_rows/building_rows/IDLE_TEXT/NONE_TEXTbecomeDetailsSection.….panel.rows_gridbecomes_details(panel).rows_grid, through a new_details()helper that returns%DetailsSection.panel.tasks/panel.buildingsbecomepanel.context.tasks/panel.context.buildings.test/unit/test_inspector_sections.gd(new):%DetailsSectionis the only section by default; a section added at runtime withadd_child()is listed after it; a non-section child is neither listed nor hidden.context(including a replaced one); refreshed again on a tick; hidden on the next tick once it stops applying and shown again when it applies again; hidden when the selection switches to a pawn.test/support/stub_inspector_section.gd(new, noclass_name, preloaded by path likestub_task.gd): applies only to buildings whileappliesis true, and recordsrefresh_calls,last_entityandlast_ctx.Coverage against the acceptance criteria:
test_the_details_section_is_stacked_by_default, and the #142 panel tests through_details()tools/screenshot_inspector.gdadd_child()alone. Also review:inspector_panel.gdnames no concrete sectiontest_inspector_panel.gdis renames only2.8 Before posting, check that
emit(...)call written out (none changed)The spec was checked against a throwaway implementation that followed this outline. The full suite went green (43 scripts, 948 tests), and the implementation was then discarded.