Add basic support for registering custom icons that override icons provided by the system/modules

This commit is contained in:
Oliver 2026-02-09 23:06:51 -07:00
parent c90137b18f
commit c7541db1d9
9 changed files with 279 additions and 6 deletions

View file

@ -62,6 +62,11 @@
}
},
"menu": {
"customStatusIcons": {
"name": "Custom Status Icons",
"hint": "(v13+) Modify your token status effect icons without needing a custom module or a world script to do so! Pick any image on your server for the icons!",
"label": "Configure Icons"
},
"devSettings": {
"name": "Developer Settings",
"hint": "Tweaks that are relevant if you are developing something within Foundry, but are rarely useful outside of that context.",
@ -81,7 +86,13 @@
},
"apps": {
"no-settings-to-display": "No settings to display",
"make-global-reference": "Make Global Reference"
"make-global-reference": "Make Global Reference",
"StatusEffectIconConfig": {
"title": "Configure Status Effect Icons",
"no-status-effects": "No status effects detected, this is most likely due to your game system or other modules.",
"remove-override": "Remove custom override",
"select-using-image-tagger": "Select using Image Tagger"
}
},
"notifs": {
"toggleMouseBroadcast": {

View file

@ -0,0 +1,132 @@
import { __ID__, filePath } from "../consts.mjs";
import { key as customStatusIconsKey } from "../tweaks/customStatusIcons.mjs";
const { HandlebarsApplicationMixin, ApplicationV2 } = foundry.applications.api;
const { SettingsConfig } = foundry.applications.settings;
export class StatusEffectIconConfig extends HandlebarsApplicationMixin(ApplicationV2) {
// #region Options
static DEFAULT_OPTIONS = {
tag: `form`,
classes: [
__ID__,
`StatusEffectIconConfig`,
],
window: {
title: `OFT.apps.StatusEffectIconConfig.title`,
},
position: {
width: 550,
},
form: {
handler: this.#onSubmit,
closeOnSubmit: true,
submitOnChange: false,
},
actions: {
pickViaImageTagger: this.#pickViaImageTagger,
removeOverride: this.#removeOverride,
},
};
static PARTS = {
list: {
template: filePath(`templates/StatusEffectIconConfig/effects.hbs`),
scrollable: [``],
},
footer: {
template: filePath(`templates/StatusEffectIconConfig/footer.hbs`),
},
};
// #endregion Options
// #region Instance Data
#overrides = null;
#originalOverrides = null;
// #endregion Instance Data
// #region Lifecycle
async _onRender() {
const pickers = this.element.querySelectorAll(`file-picker`);
for (const picker of pickers) {
picker.addEventListener(`change`, this.#onChangeFilePicker.bind(this));
};
};
// #endregion Lifecycle
// #region Data Prep
_prepareContext() {
const ctx = {
meta: {
idp: this.id,
},
showImageTaggerButton: game.modules.get(`image-tagger`).active,
};
const effects = Object.values(CONFIG.statusEffects);
this.#overrides ??= game.settings.get(__ID__, customStatusIconsKey);
this.#originalOverrides ??= foundry.utils.deepClone(this.#overrides);
// console.log({ original: this.#originalOverrides, current: this.#overrides });
ctx.effects = [];
for (const effect of effects) {
let preview = this.#overrides[effect.id] ?? effect.img;
if (
this.#originalOverrides[effect.id] != null
&& this.#overrides[effect.id] === null
) {
preview = null;
}
ctx.effects.push({
id: effect.id,
preview,
name: game.i18n.localize(effect.name),
img: this.#overrides[effect.id],
hasOverride: this.#overrides[effect.id] != null,
});
};
return ctx;
};
// #endregion Data Prep
// #region Event Listeners
async #onChangeFilePicker(event) {
const target = event.currentTarget;
const id = target.closest(`[data-effect-id]`).dataset.effectId;
this.#overrides[id] = target.value || null;
await this.render();
};
/** @this {StatusEffectIconConfig} */
static async #onSubmit() {
game.settings.set(__ID__, customStatusIconsKey, this.#overrides);
SettingsConfig.reloadConfirm({ world: true });
};
// #endregion Event Listeners
// #region Actions
/** @this {StatusEffectIconConfig} */
static async #pickViaImageTagger(event, element) {
const id = element.closest(`[data-effect-id]`)?.dataset.effectId;
if (!id) { return };
const ArtBrowser = game.modules.get(`image-tagger`).api.Apps.ArtBrowser;
const newImage = await ArtBrowser.select();
if (!newImage) { return };
this.#overrides[id] = newImage;
await this.render();
};
/** @this {StatusEffectIconConfig} */
static async #removeOverride(event, element) {
const id = element.closest(`[data-effect-id]`)?.dataset.effectId;
this.#overrides[id] = null;
await this.render();
};
// #endregion Actions
};

View file

@ -4,6 +4,7 @@ import { addGlobalDocReferrer } from "../tweaks/addGlobalDocReferrer.mjs";
import { autoUnpauseOnLoad } from "../tweaks/autoUnpauseOnLoad.mjs";
import { chatImageLinks } from "../tweaks/chatImageLinks.mjs";
import { chatSidebarBackground } from "../tweaks/chatSidebarBackground.mjs";
import { customStatusIcons } from "../tweaks/customStatusIcons.mjs";
import { defaultHotbarPage } from "../tweaks/defaultHotbarPage.mjs";
import { hotbarButtonGap } from "../tweaks/hotbarButtonGap.mjs";
import { hotbarButtonSize } from "../tweaks/hotbarButtonSize.mjs";
@ -48,6 +49,7 @@ Hooks.on(`setup`, () => {
hotbarButtonGap();
repositionHotbar();
customStatusIcons();
chatImageLinks();
chatSidebarBackground();
startSidebarExpanded();

View file

@ -0,0 +1,44 @@
import { SettingStatusEnum, status } from "../utils/SettingStatus.mjs";
import { __ID__ } from "../consts.mjs";
import { Logger } from "../utils/Logger.mjs";
import { preventTweakRegistration } from "../utils/preRegisterTweak.mjs";
import { StatusEffectIconConfig } from "../apps/StatusEffectIconConfig.mjs";
export const key = `customStatusIcons`;
export function customStatusIcons() {
status[key] = SettingStatusEnum.Unknown;
if (preventTweakRegistration(key)) { return };
// #region Registration
Logger.log(`Registering tweak: ${key}`);
game.settings.registerMenu(__ID__, `${key}Menu`, {
name: `OFT.menu.${key}.name`,
hint: `OFT.menu.${key}.hint`,
label: `OFT.menu.${key}.label`,
restricted: true,
type: StatusEffectIconConfig,
});
game.settings.register(__ID__, key, {
scope: `world`,
config: false,
type: Object,
default: {},
});
// #endregion Registration
// #region Implementation
Hooks.on(`ready`, () => {
const value = game.settings.get(__ID__, key);
const effects = Object.values(CONFIG.statusEffects);
for (const effect of effects) {
if (value[effect.id] != null) {
effect.img = value[effect.id];
};
};
});
// #endregion Implementation
status[key] = SettingStatusEnum.Registered;
};

View file

@ -0,0 +1,34 @@
.oft.StatusEffectIconConfig {
.effect-list {
list-style-type: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 16px;
}
.effect {
display: flex;
flex-direction: row;
gap: 8px;
align-items: center;
margin: 0;
.preview {
--size: 30px;
width: var(--size);
height: var(--size);
display: flex;
justify-content: center;
align-items: center;
}
h2 {
margin: 0;
font-size: 1rem;
font-family: var(--font-body);
flex-grow: 1;
}
}
}

View file

@ -1,9 +1,12 @@
@import url("./chatSidebarBackground.css");
@import url("./hotbarButtonGap.css");
@import url("./hotbarButtonSize.css");
@import url("./repositionHotbar.css");
@layer resets, elements, tweaks, apps;
@import url("./apps.css");
@import url("./chatSidebarBackground.css") layer(tweaks);
@import url("./hotbarButtonGap.css") layer(tweaks);
@import url("./hotbarButtonSize.css") layer(tweaks);
@import url("./repositionHotbar.css") layer(tweaks);
@import url("./apps/common.css") layer(apps);
@import url("./apps/StatusEffectIconConfig.css") layer(apps);
/* Make the chat sidebar the same width as all the other tabs */
.chat-sidebar:not(.sidebar-popout) { width: var(--sidebar-width); }

View file

@ -0,0 +1,42 @@
<div class="scrollable">
{{#if effects}}
<ul class="effect-list">
{{#each effects as | effect |}}
<li class="effect" data-effect-id="{{ effect.id }}">
<div class="preview">
{{#if effect.preview}}
<img
src="{{effect.preview}}"
alt=""
>
{{/if}}
</div>
<h2>{{ effect.name }}</h2>
<file-picker
value="{{effect.img}}"
></file-picker>
{{#if @root.showImageTaggerButton}}
<button
type="button"
class="icon fa-solid fa-paintbrush"
aria-label="{{localize "OFT.apps.StatusEffectIconConfig.select-using-image-tagger"}}"
data-tooltip
data-action="pickViaImageTagger"
></button>
{{/if}}
{{#if effect.hasOverride}}
<button
type="button"
class="icon fa-solid fa-xmark"
aria-label="{{localize "OFT.apps.StatusEffectIconConfig.remove-override"}}"
data-tooltip
data-action="removeOverride"
></button>
{{/if}}
</li>
{{/each}}
</ul>
{{else}}
{{ localize "OFT.apps.StatusEffectIconConfig.no-status-effects" }}
{{/if}}
</div>

View file

@ -0,0 +1,5 @@
<div>
<button type="submit">
Save
</button>
</div>