Additive UI Elements

RealityMeltdown

Chieftain
Joined
Feb 13, 2025
Messages
7
I know a number of modders have added lenses, however everyone I've seen has done it using the "import", which seems to overwrite other classes - so only one mod at a time

After alot of tinkering, I managed to do it using only UIScript definitions. This is cool because it means multiple modders can add lenses together.

I haven't had time to do much with it, but I wanted to post this here as well as in the Civ 7 discord's modding section so that all y'all can work independently!

This used the religion lens mod made by @Craimasjien as a starting point.

[Check below for code, my original was improved]

Not sure if I'll be able to respond for awhile if you have questions, but for all UI modders, I wanted anyone interested in this method to take it from here.
 
Last edited:
great, seems to be a bit similar to how it worked in civ6.
 
This is great work @RealityMeltdown! I've published a new version of my mod using this method and it works spectacularly. I think we need to make sure this will become the defacto standard of building mods as it highly reduces the chance on complications and mods being incompatible with each other. I tried reaching out to @sukritact through discord to check if he's aware so he can potentially try and apply this to his mods as well.
 
Also, I was able to trim the code down to the following:

JavaScript:
/**
 * Huge shoutout to @realitymeltdown in the official Civ VII Discord for figuring this out.
 */
export class CmpPanelMiniMapDecorator {
    constructor(val) {
        this.miniMap = val;
    }

    beforeAttach() {
    }

    afterAttach() {
        this.miniMap.createLensButton("LOC_UI_MINI_MAP_RELIGION", "cmp-religion-lens", "lens-group");
    }

    beforeDetach() { }

    afterDetach() { }

    onAttributeChanged(name, prev, next) { }
}

Controls.decorate('panel-mini-map', (val) => new CmpPanelMiniMapDecorator(val));

I believe that Controls is globally statically available, so we don't need to import anything. And the original snippet from @RealityMeltdown imports the
JavaScript:
ContextManager
instead of the
JavaScript:
ComponentManager
, so I don't think those lines actually do anything for the end result.
 
Assuming this would apply to other UI elements as well? Would love to make my health bars compatible with sukritact's and other unit-flags.js mods.
 
Add to this, you can also get the ComponentRoot (root HTMLElement of the component) through component.Root. In the above example, this.miniMap.Root. This way you can use standard html to attach new UI components to this ComponentRoot. Also you can listen to its attributes update through MutationObserver. Most of UI's data are passed through attributes.
 
If you're using this to add a new map layer/lens they changed the class in 1.1.0. Make you're decorating 'lens-panel' now instead of 'panel-mini-map':

JavaScript:
Controls.decorate('lens-panel', (val) => new CmpPanelMiniMapDecorator(val));
 
In my Completed Production mod I used a decorator to add a new custom defined element to the city production screen so it should work with any mods that are modifying it.

In the decorator's after attach function I added the element like so:
JavaScript:
    afterAttach() {
        this.panel.productionPurchaseContainer.insertAdjacentHTML('beforebegin', `<completed-production></completed-production>`);
    }

Then created a class to handle that custom defined element:
JavaScript:
import { CompletedProductionManagerInstance } from '/completed-production/code/completed-production.js';
import { ComponentID } from '/core/ui/utilities/utilities-component-id.js';

class CompletedProductionHeader extends Component {
    constructor() {
        super(...arguments);
        this.cityID = null;
        this.container = document.createElement('div');
        this.producedText = document.createElement('div');
    }
    onInitialize() {
        super.onInitialize();
        this.Root.classList.add('flex', 'flex-col', 'm-1', 'w-128');
        this.cityID = UI.Player.getHeadSelectedCity();

        this.render();
    }
    onAttach() {
        this.refresh(); // refresh here so if we're reattaching we're up to date
        engine.on('CitySelectionChanged', this.onCitySelectionChanged, this);
    }
    onDetach() {
        engine.off('CitySelectionChanged', this.onCitySelectionChanged, this);
    }
    onCitySelectionChanged({ cityID }) {
        if (ComponentID.isMatch(this.cityID, cityID)) {
            return;
        }
        this.cityID = cityID;
        this.refresh();
    }
    refresh() {
        const lastItem = CompletedProductionManagerInstance.getLastProduced(this.cityID.id);
        const lastItemStr = lastItem == null ? Locale.compose('LOC_LAST_PRODUCED_ITEM', "Unknown") : Locale.compose('LOC_LAST_PRODUCED_ITEM', lastItem.productionName);
        this.producedText.setAttribute('data-l10n-id', lastItemStr);

        if (Cities.get(this.cityID).isTown || lastItem == null || lastItem.turn != Game.turn) {
            this.Root.classList.add('hidden');
        }
        else {
            this.Root.classList.remove('hidden');
        }
    }
    render() {
        this.container.className = 'completed-production-container flex flex-col m-1 w-128';
        this.producedText.className = 'font-title self-center text-xl';
        this.container.appendChild(this.producedText);
        this.Root.appendChild(this.container);
    }
};
Controls.define('completed-production', {
    createInstance: CompletedProductionHeader
});

The constructor creates any sub-elements that will need to be referenced later in our update function. The render function is used to set up the html elements within our custom element appending the sub-elements we created in the constructor. Then the class registers to any events it needs in order to update. The refresh function is updating the information on our element. In this case by updating the displayed info whenever the player selects a new city and hiding itself when not needed.
 
Is the onAttributeChanged function actually getting called in the decorator? I tried decorating tree-card from the tech/culture tree display which gets the unlock data added after object creation so I needed to wait for the attribute update but onAttributeChanged was never getting called. I ended up patching out the relevant tree-card function so I can grab the data after its added.
 
Is the onAttributeChanged function actually getting called in the decorator? I tried decorating tree-card from the tech/culture tree display which gets the unlock data added after object creation so I needed to wait for the attribute update but onAttributeChanged was never getting called. I ended up patching out the relevant tree-card function so I can grab the data after its added.
It does not. You can actually check the decorator interface in component-support.js to learn more, but pretty much only attach/detach callbacks are supported. To listen to attributes changes in decorators, you can create a MutationObserver for the component root. In your case, it’s the tree-card. You can learn more about this usage in my mod https://forums.civfanatics.com/resources/detailed-tech-civic-progress.31924/. I have a tree-card decorator there.
 
It does not. You can actually check the decorator interface in component-support.js to learn more, but pretty much only attach/detach callbacks are supported. To listen to attributes changes in decorators, you can create a MutationObserver for the component root. In your case, it’s the tree-card. You can learn more about this usage in my mod https://forums.civfanatics.com/resources/detailed-tech-civic-progress.31924/. I have a tree-card decorator there.
Oh, that's exactly what I was looking for and makes that process significantly easier. Thanks!
 
Back
Top Bottom