Hello,
I’m struggling with a seemingly simple task: I want to display a single ampersand character (&) in my action’s text.
What I’ve tried:
text="Project & Non-Project Modules" → Result: The “N” is underlined as a mnemonic.
text="Project && Non-Project Modules" → Result: No mnemonic and no (&).
text="Project && Non-Project Modules" → Result: Same as above.
I have defined my action in plugin.xml as follows:
<action id="pl.atingo.bts.actions.OdooModuleTestProjectNonProjectModules"
class="pl.atingo.bts.actions.OdooModuleTestProjectNonProjectModules"
text="Project && Non-Project Modules">
<keyboard-shortcut
keymap="$default"
first-keystroke="alt B"
second-keystroke="N"/>
</action>
abrooksv
(Austin Brooks)
April 13, 2026, 5:55pm
2
You need to say &&, see
if (s.endsWith(ELLIPSIS)) {
return s.substring(0, s.length() - ELLIPSIS.length());
}
return s;
}
private static final List<String> MN_QUOTED = Arrays.asList("&&", "__");
private static final List<String> MN_CHARS = Arrays.asList("&", "_");
@Contract(pure = true)
public static @NotNull String escapeMnemonics(@NotNull String text) {
return replace(text, MN_CHARS, MN_QUOTED);
}
@Contract(pure = true)
public static @NlsSafe @NotNull String htmlEmphasize(@NotNull @Nls String text) {
return HtmlChunk.tag("code").addText(text)
.wrapWith("b").toString();
}
Example in properties file:
Thank you for your reply.
When I use && in plugin.xml :
<action id="pl.atingo.bts.actions.OdooModuleTestProjectNonProjectModules"
class="pl.atingo.bts.actions.OdooModuleTestProjectNonProjectModules"
text="Project && Non-Project Modules">
<keyboard-shortcut
keymap="$default"
first-keystroke="alt B"
second-keystroke="N"/>
</action>
Then I get:
The entity name must immediately follow the '&' in the entity reference.
And when I use it in bundle.properties :
action.pl.atingo.bts.actions.OdooModuleTestWithRemoveDB.description=Project && Non-Project Modules
Then I get:
com.intellij.diagnostic.PluginException: Empty menu item text for OdooModuleTestProjectNonProjectModules@popup (pl.atingo.bts.actions.OdooModuleTestProjectNonProjectModules). The default action text must be specified in plugin.xml or its class constructor [Plugin: pl.atingo.bts]
abrooksv
(Austin Brooks)
April 13, 2026, 6:19pm
4
Looks like you have a typo in the properties file example
your ID is pl.atingo.bts.actions.OdooModuleTestProjectNonProjectModules but you registered it with the ID pl.atingo.bts.actions.OdooModuleTestWithRemoveDB
My apologies, I completely messed up the examples in my previous post. I’ve been testing so many different configurations that I got lost in my own snippets. Let’s clear this up:
The plugin.xml issue: When I try to use && directly in the text attribute like this: text="Project && Non-Project Modules" I get the expected XML error: The entity name must immediately follow the '&' in the entity reference. This is clear, as XML requires entities.
The bundle.properties issue (with the correct ID this time): When I use the correct action ID in my properties file: action.pl.atingo.bts.actions.OdooModuleTestProjectNonProjectModules.text=Project && Non-Project Modules, the action loads correctly, but the & character is simply not displayed .
abrooksv
(Austin Brooks)
April 14, 2026, 4:51pm
6
Interesting, wonder if its a bug…?
It works with && only if its done through the AnAction constructor
AnAction("Foo && Bar") {
abrooksv
(Austin Brooks)
April 14, 2026, 5:44pm
7
I got curious so I debugged this:
When using ActionStub (defining it in the properties file), the text is loaded via
descriptionValue: String?,
classLoader: ClassLoader): @NlsActions.ActionDescription String? {
var effectiveBundle = bundleSupplier()
if (effectiveBundle != null && DefaultBundleService.isDefaultBundle()) {
effectiveBundle = DynamicBundle.getResourceBundle(classLoader, effectiveBundle.baseBundleName)
}
return AbstractBundle.messageOrDefault(effectiveBundle, "$elementType.$id.$DESCRIPTION", descriptionValue ?: "")
}
@Suppress("HardCodedStringLiteral")
private fun computeActionText(bundleSupplier: () -> ResourceBundle?,
id: String,
elementType: String,
textValue: String?,
classLoader: ClassLoader): @NlsActions.ActionText String? {
var effectiveBundle = bundleSupplier()
if (effectiveBundle != null && DefaultBundleService.isDefaultBundle()) {
effectiveBundle = DynamicBundle.getResourceBundle(classLoader, effectiveBundle.baseBundleName)
}
if (effectiveBundle == null) {
return textValue
Which has to go through Java’s Message format and this util:
}
@JvmStatic
@Contract(pure = true)
fun format(value: String, vararg params: Any): String {
return if (params.isNotEmpty() && value.contains('{')) MessageFormat.format(value, *params) else value
}
@JvmStatic
@Contract("null -> null; !null -> !null")
fun replaceMnemonicAmpersand(value: @Nls String?): @Nls String? {
if (value == null || !value.contains('&') || value.contains(MNEMONIC)) {
return value
}
val builder = StringBuilder()
val macMnemonic = value.contains("&&")
var mnemonicAdded = false
var i = 0
while (i < value.length) {
when (val c = value[i]) {
So in order to get it formatted correct from the resource bundle PoV it would need to be:
action.foo.bar.text=Foo \\& Bar
But the format is then passed to Presentation.setText supplier, which wants it in && format.
So we have to triple escape this for all the layers:
& -> && for com.intellij.openapi.util.text.TextWithMnemonic#parse
&& -> \&\& for com.intellij.BundleBase#replaceMnemonicAmpersand
\&\& -> \\&\\& for Java MessageFormat
leading us to
action.foo.bar.text=Foo \\&\\& Bar
That’s it! \\&\\& did the trick
Thank you so much for digging into the code and debugging this.
Cheers!