[util] IJPL-208889 MarkupText experimental API

A simple model of a text line with markup which doesn't depend on Swing to be used in  completion, etc.

GitOrigin-RevId: 3aef202aa8f00edf149f11f6d3b859456ba44fe0
This commit is contained in:
Tagir Valeev
2025-09-22 09:27:23 +02:00
committed by intellij-monorepo-bot
parent 228f01a833
commit ea5fa1b63f
8 changed files with 580 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
@Internal
package com.intellij.modcompletion;
import org.jetbrains.annotations.ApiStatus.Internal;

View File

@@ -13,5 +13,7 @@
- getToolTip(Z):java.lang.String
- hashCode():I
- paintIcon(java.awt.Component,java.awt.Graphics,I,I):V
f:com.intellij.ui.SimpleTextAttributes
- *s:fromMarkupTextKind(com.intellij.openapi.util.text.MarkupText$Kind):com.intellij.ui.SimpleTextAttributes
f:com.intellij.util.IconUtil
- *sf:downscaleIconToSize(javax.swing.Icon,I,I):javax.swing.Icon

View File

@@ -4,11 +4,13 @@ package com.intellij.ui;
import com.intellij.openapi.editor.markup.EffectType;
import com.intellij.openapi.editor.markup.TextAttributes;
import com.intellij.openapi.util.Comparing;
import com.intellij.openapi.util.text.MarkupText;
import com.intellij.util.BitUtil;
import com.intellij.util.ui.JBUI;
import com.intellij.util.ui.NamedColorUtil;
import org.intellij.lang.annotations.JdkConstants;
import org.intellij.lang.annotations.MagicConstant;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
@@ -203,6 +205,25 @@ public final class SimpleTextAttributes {
return BitUtil.isSet(myStyle, STYLE_FADED);
}
/**
* Adapts {@link MarkupText.Kind} to {@code SimpleTextAttributes}.
*
* @param kind kind of {@link MarkupText}
* @return the corresponding {@code SimpleTextAttributes} object
*/
@ApiStatus.Experimental
public static @NotNull SimpleTextAttributes fromMarkupTextKind(MarkupText.@NotNull Kind kind) {
return switch (kind) {
case NORMAL -> REGULAR_ATTRIBUTES;
case EMPHASIZED -> REGULAR_ITALIC_ATTRIBUTES;
case STRONG -> REGULAR_BOLD_ATTRIBUTES;
case UNDERLINED -> new SimpleTextAttributes(STYLE_UNDERLINE, null);
case ERROR -> ERROR_ATTRIBUTES;
case STRIKEOUT -> new SimpleTextAttributes(STYLE_STRIKEOUT, null);
case GRAYED -> GRAYED_ATTRIBUTES;
};
}
public static @NotNull SimpleTextAttributes fromTextAttributes(TextAttributes attributes) {
if (attributes == null) return REGULAR_ATTRIBUTES;

View File

@@ -375,6 +375,7 @@ c:com.intellij.ui.SimpleColoredComponent
- javax.swing.JComponent
- com.intellij.ui.ColoredTextContainer
- javax.accessibility.Accessible
- *s:fromMarkupText(com.intellij.openapi.util.text.MarkupText):com.intellij.ui.SimpleColoredComponent
- *p:getIconToolTipText():java.lang.String
*c:com.intellij.ui.components.JBHtmlPane
- com.intellij.openapi.Disposable

View File

@@ -13,6 +13,7 @@ import com.intellij.openapi.ui.GraphicsConfig;
import com.intellij.openapi.util.Comparing;
import com.intellij.openapi.util.NlsSafe;
import com.intellij.openapi.util.SystemInfo;
import com.intellij.openapi.util.text.MarkupText;
import com.intellij.openapi.util.text.StringUtil;
import com.intellij.ui.icons.IconWithToolTip;
import com.intellij.ui.paint.EffectPainter;
@@ -284,6 +285,20 @@ public class SimpleColoredComponent extends JComponent implements Accessible, Co
revalidateAndRepaint();
}
/**
* Creates a {@code SimpleColoredComponent} from {@link MarkupText}.
* @param text markup text
* @return a newly created {@code SimpleColoredComponent} that visualizes the specified text
*/
@ApiStatus.Experimental
public static SimpleColoredComponent fromMarkupText(@NotNull MarkupText text) {
SimpleColoredComponent component = new SimpleColoredComponent();
for (MarkupText.Fragment fragment : text.fragments()) {
component.append(fragment.text(), SimpleTextAttributes.fromMarkupTextKind(fragment.kind()));
}
return component;
}
private void _clear() {
synchronized (myFragments) {
myIcon = null;

View File

@@ -65,6 +65,43 @@ f:com.intellij.openapi.util.text.HtmlChunkUtilKt
- *sf:buildHtml(kotlin.jvm.functions.Function1):java.lang.String
- *sf:buildHtmlChunk(kotlin.jvm.functions.Function1):com.intellij.openapi.util.text.HtmlChunk
- *sf:plus(com.intellij.openapi.util.text.HtmlChunk,com.intellij.openapi.util.text.HtmlChunk):com.intellij.openapi.util.text.HtmlChunk
*f:com.intellij.openapi.util.text.MarkupText
- s:builder():com.intellij.openapi.util.text.MarkupText$MarkupTextBuilder
- concat(com.intellij.openapi.util.text.MarkupText):com.intellij.openapi.util.text.MarkupText
- concat(java.lang.String,com.intellij.openapi.util.text.MarkupText$Kind):com.intellij.openapi.util.text.MarkupText
- equals(java.lang.Object):Z
- fragments():java.util.List
- hashCode():I
- highlightRange(I,I,com.intellij.openapi.util.text.MarkupText$Kind):com.intellij.openapi.util.text.MarkupText
- isEmpty():Z
- length():I
- s:plainText(java.lang.String):com.intellij.openapi.util.text.MarkupText
- toHtmlChunk():com.intellij.openapi.util.text.HtmlChunk
- toText():java.lang.String
*f:com.intellij.openapi.util.text.MarkupText$Fragment
- <init>(java.lang.String,com.intellij.openapi.util.text.MarkupText$Kind):V
- equals(java.lang.Object):Z
- hashCode():I
- kind():com.intellij.openapi.util.text.MarkupText$Kind
- text():java.lang.String
- toHtmlChunk():com.intellij.openapi.util.text.HtmlChunk
*e:com.intellij.openapi.util.text.MarkupText$Kind
- java.lang.Enum
- sf:EMPHASIZED:com.intellij.openapi.util.text.MarkupText$Kind
- sf:ERROR:com.intellij.openapi.util.text.MarkupText$Kind
- sf:GRAYED:com.intellij.openapi.util.text.MarkupText$Kind
- sf:NORMAL:com.intellij.openapi.util.text.MarkupText$Kind
- sf:STRIKEOUT:com.intellij.openapi.util.text.MarkupText$Kind
- sf:STRONG:com.intellij.openapi.util.text.MarkupText$Kind
- sf:UNDERLINED:com.intellij.openapi.util.text.MarkupText$Kind
- s:valueOf(java.lang.String):com.intellij.openapi.util.text.MarkupText$Kind
- s:values():com.intellij.openapi.util.text.MarkupText$Kind[]
*f:com.intellij.openapi.util.text.MarkupText$MarkupTextBuilder
- append(com.intellij.openapi.util.text.MarkupText$Fragment):com.intellij.openapi.util.text.MarkupText$MarkupTextBuilder
- append(com.intellij.openapi.util.text.MarkupText):com.intellij.openapi.util.text.MarkupText$MarkupTextBuilder
- append(java.lang.String):com.intellij.openapi.util.text.MarkupText$MarkupTextBuilder
- append(java.lang.String,com.intellij.openapi.util.text.MarkupText$Kind):com.intellij.openapi.util.text.MarkupText$MarkupTextBuilder
- build():com.intellij.openapi.util.text.MarkupText
com.intellij.ui.IconManager
- *:colorizedIcon(javax.swing.Icon,kotlin.jvm.functions.Function0):javax.swing.Icon
*f:com.intellij.util.JavaCoroutines

View File

@@ -0,0 +1,381 @@
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.openapi.util.text;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.Nls;
import org.jetbrains.annotations.NotNullByDefault;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
/**
* Represents an immutable text string with markup to display in the UI. It's used to display simple one-line text,
* like a completion item or a menu item. It's not intended to provide any complex markup like text positioning, indentation, margins, etc.
* Only semantic visual markup is supported.
*/
@ApiStatus.Experimental
@NotNullByDefault
public final class MarkupText {
private static final MarkupText EMPTY = new MarkupText(Collections.emptyList());
private final List<Fragment> fragments;
/**
* @param fragments list of fragments. They should be displayed one after another.
*/
private MarkupText(List<Fragment> fragments) { this.fragments = fragments; }
/**
* @return list of fragments.
*/
public List<Fragment> fragments() { return fragments; }
@Override
public boolean equals(Object obj) {
if (obj == this) return true;
if (obj == null || obj.getClass() != this.getClass()) return false;
MarkupText that = (MarkupText)obj;
return Objects.equals(this.fragments, that.fragments);
}
@Override
public int hashCode() {
return fragments.hashCode();
}
/**
* @return textual representation for debug purposes only. It somehow mimics Markdown, but it's not a real Markdown.
*/
@Override
public String toString() {
return StringUtil.join(fragments, "");
}
/**
* @return HTML representation of the MarkupText. It's assumed that a few CSS classes are defined; namely "grayed" and "error".
*/
public HtmlChunk toHtmlChunk() {
HtmlBuilder builder = new HtmlBuilder();
fragments.forEach(fragment -> builder.append(fragment.toHtmlChunk()));
return builder.toFragment();
}
/**
* @return text of the MarkupText with all the markup stripped out.
*/
public @Nls String toText() {
return StringUtil.join(fragments, Fragment::text, "");
}
/**
* @return true if this MarkupText is empty.
*/
public boolean isEmpty() {
return fragments.isEmpty();
}
/**
* @return total length of the MarkupText in characters.
*/
public int length() {
return fragments.stream().mapToInt(f -> f.text.length()).sum();
}
/**
* Concatenates two MarkupTexts.
* @param other MarkupText to be concatenated with this one
* @return a MarkupText that contains all the fragments of both MarkupTexts.
*/
public MarkupText concat(MarkupText other) {
if (this.isEmpty()) return other;
if (other.isEmpty()) return this;
return new MarkupTextBuilder().append(this).append(other).build();
}
/**
* Appends a text fragment to this MarkupText.
*
* @param text text to be appended
* @param kind text kind
* @return a MarkupText that contains the content of this MarkupText and the specified text fragment.
*/
public MarkupText concat(@Nls String text, Kind kind) {
if (text.isEmpty()) return this;
return new MarkupTextBuilder().append(this).append(text, kind).build();
}
/**
* Change the formatting of the specified range
*
* @param fromInclusive start of the range (offset in characters)
* @param toExclusive end of the range (offset in characters)
* @param kind new highlighting kind for the range. All old highlighting in the specified range will be replaced.
* @return a MarkupText with updated highlighting
* @throws IllegalArgumentException if the range is invalid
*/
public MarkupText highlightRange(int fromInclusive, int toExclusive, Kind kind) {
if (fromInclusive > toExclusive ||
fromInclusive < 0) {
throw new IllegalArgumentException("Invalid range: " + fromInclusive + ".." + toExclusive);
}
if (fromInclusive == toExclusive || isEmpty()) return this;
MarkupTextBuilder newFragments = new MarkupTextBuilder();
int start = 0;
@Nls StringBuilder sb = new StringBuilder();
for (Fragment fragment : fragments) {
int end = start + fragment.text.length();
if (end <= fromInclusive || start >= toExclusive) {
newFragments.append(fragment);
} else if (start < fromInclusive) {
newFragments.append(fragment.text.substring(0, fromInclusive - start), fragment.kind);
if (end >= toExclusive) {
sb.append(fragment.text, fromInclusive - start, toExclusive - start);
newFragments.append(sb.toString(), kind);
sb.setLength(0);
newFragments.append(fragment.text.substring(toExclusive - start), fragment.kind);
} else {
sb.append(fragment.text.substring(fromInclusive - start));
}
} else if (end >= toExclusive) {
sb.append(fragment.text, 0, toExclusive - start);
newFragments.append(sb.toString(), kind);
sb.setLength(0);
newFragments.append(fragment.text.substring(toExclusive - start), fragment.kind);
} else {
sb.append(fragment.text);
}
start = end;
}
if (start < fromInclusive || start < toExclusive) {
throw new IllegalArgumentException(
"Invalid range: " + fromInclusive + ".." + toExclusive + " for " + this + " (length=" + start + ")");
}
if (sb.length() > 0) {
newFragments.append(sb.toString(), kind);
}
return newFragments.build();
}
/**
* Creates a MarkupText with a single plain text fragment.
*
* @param text text to be displayed in the UI
* @return a MarkupText with a single plain text fragment.
*/
public static MarkupText plainText(@Nls String text) {
if (text.isEmpty()) return EMPTY;
return new MarkupText(Collections.singletonList(new Fragment(text, Kind.NORMAL)));
}
/**
* @return a new builder to build the MarkupText instance efficiently.
*/
public static MarkupTextBuilder builder() {
return new MarkupTextBuilder();
}
/**
* A builder for MarkupText.
*/
public static final class MarkupTextBuilder {
private final List<Fragment> fragments = new ArrayList<>();
private MarkupTextBuilder() { }
/**
* Appends a normal text fragment to this builder.
*
* @param text text to be appended
* @return this builder
*/
public MarkupTextBuilder append(@Nls String text) {
return append(text, Kind.NORMAL);
}
/**
* Appends a text fragment to this builder.
*
* @param text text to be appended
* @param kind text kind
* @return this builder
*/
public MarkupTextBuilder append(@Nls String text, Kind kind) {
if (text.isEmpty()) return this;
return append(new Fragment(text, kind));
}
/**
* Appends a fragment to this builder.
*
* @param fragment fragment to be appended
* @return this builder
*/
public MarkupTextBuilder append(Fragment fragment) {
if (fragment.text.isEmpty()) return this;
if (!fragments.isEmpty()) {
Fragment last = fragments.get(fragments.size() - 1);
if (last.kind == fragment.kind) {
fragments.remove(fragments.size() - 1);
fragment = new Fragment(last.text + fragment.text, fragment.kind);
}
}
fragments.add(fragment);
return this;
}
/**
* Appends all the fragments of another MarkupText to this builder.
*
* @param other MarkupText to be appended
* @return this builder
*/
public MarkupTextBuilder append(MarkupText other) {
other.fragments.forEach(this::append);
return this;
}
/**
* @return a MarkupText that contains all the fragments of this builder.
*/
public MarkupText build() {
if (fragments.isEmpty()) {
return EMPTY;
}
return new MarkupText(Collections.unmodifiableList(new ArrayList<>(fragments)));
}
}
/**
* Fragment kind that affects its formatting.
*/
public enum Kind {
/**
* No special markup
*/
NORMAL,
/**
* Emphasized text (likely, italic)
*/
EMPHASIZED,
/**
* Strong text (likely, bold)
*/
STRONG,
/**
* Underlined text
*/
UNDERLINED,
/**
* Error text (likely, red)
*/
ERROR,
/**
* Strikethrough text
*/
STRIKEOUT,
/**
* Grayed out text
*/
GRAYED
}
/**
* A single fragment of the MarkupText. Fragments are concatenated one after another.
*/
public static final class Fragment {
@Nls private final String text;
private final Kind kind;
/**
* Creates a new fragment.
*
* @param text text of the fragment to be displayed in UI
* @param kind fragment kind which affects its formatting
*/
public Fragment(@Nls String text, Kind kind) {
this.text = text;
this.kind = kind;
}
/**
* @return text of the fragment to be displayed in UI
*/
public @Nls String text() { return text; }
/**
* @return fragment kind which affects its formatting
*/
public Kind kind() { return kind; }
@Override
public boolean equals(Object obj) {
if (obj == this) return true;
if (obj == null || obj.getClass() != this.getClass()) return false;
Fragment that = (Fragment)obj;
return Objects.equals(this.text, that.text) &&
Objects.equals(this.kind, that.kind);
}
@Override
public int hashCode() {
return Objects.hash(text, kind);
}
/**
* @return HTML representation of this fragment. It's assumed that a few CSS classes are defined; namely "grayed" and "error".
*/
public HtmlChunk toHtmlChunk() {
switch (kind) {
case NORMAL:
return HtmlChunk.text(text);
case EMPHASIZED:
return HtmlChunk.text(text).italic();
case STRONG:
return HtmlChunk.text(text).bold();
case UNDERLINED:
return HtmlChunk.text(text).wrapWith("u");
case ERROR:
return HtmlChunk.text(text).wrapWith(HtmlChunk.span().setClass("error"));
case STRIKEOUT:
return HtmlChunk.text(text).wrapWith("s");
case GRAYED:
return HtmlChunk.text(text).wrapWith(HtmlChunk.span().setClass("grayed"));
default:
throw new InternalError();
}
}
/**
* @return textual representation for debug purposes only. It somehow mimics Markdown, but it's not a real Markdown.
*/
@Override
public String toString() {
switch (kind) {
case NORMAL:
return text;
case EMPHASIZED:
return "*" + text + "*";
case STRONG:
return "**" + text + "**";
case UNDERLINED:
return "_" + text + "_";
case ERROR:
return "!!!" + text + "!!!";
case STRIKEOUT:
return "~~" + text + "~~";
case GRAYED:
return "[" + text + "]";
default:
throw new InternalError();
}
}
}
}

View File

@@ -0,0 +1,118 @@
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.util.text;
import com.intellij.openapi.util.text.HtmlChunk;
import com.intellij.openapi.util.text.MarkupText;
import com.intellij.openapi.util.text.MarkupText.Fragment;
import org.jetbrains.annotations.NotNull;
import org.junit.Test;
import java.util.List;
import static org.junit.Assert.*;
public final class MarkupTextTest {
@Test
public void testCreate() {
MarkupText markupText = MarkupText.plainText("foo");
assertEquals("foo", markupText.toString());
assertEquals(List.of(new Fragment("foo", MarkupText.Kind.NORMAL)), markupText.fragments());
assertSame(MarkupText.plainText(""), MarkupText.plainText(""));
assertSame(MarkupText.plainText(""), MarkupText.plainText("").concat("", MarkupText.Kind.GRAYED));
}
@Test
public void testEquals() {
MarkupText markupText1 = buildLong();
MarkupText markupText2 = buildLong();
assertEquals(markupText1, markupText2);
assertEquals(markupText1.hashCode(), markupText2.hashCode());
assertEquals(markupText1.concat("", MarkupText.Kind.NORMAL), markupText2);
assertNotEquals(markupText1.concat("1", MarkupText.Kind.NORMAL), markupText2);
}
@Test
public void testConcat() {
MarkupText markupText = buildLong();
assertEquals("Hello !!!error!!! **bold** *italic* _underlined_ ~~strikeout~~ [grayed]", markupText.concat("", MarkupText.Kind.NORMAL).toString());
assertEquals("Hello !!!error!!! **bold** *italic* _underlined_ ~~strikeout~~ [grayed]1", markupText.concat("1", MarkupText.Kind.NORMAL).toString());
assertEquals("Hello !!!error!!! **bold** *italic* _underlined_ ~~strikeout~~ [grayed1]", markupText.concat("1", MarkupText.Kind.GRAYED).toString());
}
@Test
public void testIsEmpty() {
MarkupText markupText = buildLong();
assertFalse(markupText.isEmpty());
assertTrue(MarkupText.plainText("").isEmpty());
}
@Test
public void testLength() {
MarkupText markupText = buildLong();
assertEquals(51, markupText.length());
assertEquals(0, MarkupText.plainText("").length());
}
@Test
public void testBuilder() {
MarkupText markupText = buildLong();
assertEquals("Hello !!!error!!! **bold** *italic* _underlined_ ~~strikeout~~ [grayed]", markupText.toString());
assertEquals(List.of(new Fragment("Hello ", MarkupText.Kind.NORMAL),
new Fragment("error", MarkupText.Kind.ERROR),
new Fragment(" ", MarkupText.Kind.NORMAL),
new Fragment("bold", MarkupText.Kind.STRONG),
new Fragment(" ", MarkupText.Kind.NORMAL),
new Fragment("italic", MarkupText.Kind.EMPHASIZED),
new Fragment(" ", MarkupText.Kind.NORMAL),
new Fragment("underlined", MarkupText.Kind.UNDERLINED),
new Fragment(" ", MarkupText.Kind.NORMAL),
new Fragment("strikeout", MarkupText.Kind.STRIKEOUT),
new Fragment(" ", MarkupText.Kind.NORMAL),
new Fragment("grayed", MarkupText.Kind.GRAYED)
), markupText.fragments());
}
@Test
public void testHighlight() {
MarkupText text = MarkupText.plainText("Hello World!").highlightRange(6, 11, MarkupText.Kind.GRAYED);
assertEquals("Hello [World]!", text.toString());
text = text.highlightRange(3, 6, MarkupText.Kind.GRAYED);
assertEquals("Hel[lo World]!", text.toString());
assertEquals("Hel[lo ]~~Wor~~[ld]!", text.highlightRange(6, 9, MarkupText.Kind.STRIKEOUT).toString());
assertEquals("Hel~~lo ~~[World]!", text.highlightRange(3, 6, MarkupText.Kind.STRIKEOUT).toString());
assertEquals("Hel[lo Wo]~~rld~~!", text.highlightRange(8, 11, MarkupText.Kind.STRIKEOUT).toString());
}
@Test
public void testToText() {
assertEquals("Hello error bold italic underlined strikeout grayed", buildLong().toText());
}
@Test
public void testToHtml() {
HtmlChunk chunk = buildLong().toHtmlChunk();
assertEquals("Hello <span class=\"error\">error</span> <b>bold</b> <i>italic</i> <u>underlined</u> <s>strikeout</s> <span class=\"grayed\">grayed</span>",
chunk.toString());
}
private static @NotNull MarkupText buildLong() {
MarkupText markupText = MarkupText.builder()
.append("Hello")
.append(" ")
.append("error", MarkupText.Kind.ERROR)
.append(" ")
.append("bo", MarkupText.Kind.STRONG)
.append("ld", MarkupText.Kind.STRONG)
.append(" ")
.append("")
.append(new Fragment("italic", MarkupText.Kind.EMPHASIZED))
.append(" ")
.append("underlined", MarkupText.Kind.UNDERLINED)
.append(" ")
.append("strikeout", MarkupText.Kind.STRIKEOUT)
.append(" ")
.append("grayed", MarkupText.Kind.GRAYED)
.build();
return markupText;
}
}