package squidpony.panel; import squidpony.annotation.Beta; import java.util.*; /** * A {@link String} divided in chunks of different colors. Use the * {@link Iterable} interface to get the pieces. * * @author smelC * * @param <T> * The type of colors; */ @Beta public interface IColoredString<T> extends Iterable<IColoredString.Bucket<T>> { /** * A convenience alias for {@code append(c, null)}. * * @param c the char to appe */ void append(/* @Nullable */ char c); /** * Mutates {@code this} by appending {@code c} to it. * * @param c * The text to append. * @param color * {@code text}'s color. Or {@code null} to let the panel decide. */ void append(char c, /* @Nullable */T color); /** * A convenience alias for {@code append(text, null)}. * * @param text */ void append(/* @Nullable */ String text); /** * Mutates {@code this} by appending {@code text} to it. Does nothing if * {@code text} is {@code null}. * * @param text * The text to append. * @param color * {@code text}'s color. Or {@code null} to let the panel decide. */ void append(/* @Nullable */String text, /* @Nullable */T color); /** * Mutates {@code this} by appending {@code i} to it. * * @param i * The int to append. * @param color * {@code text}'s color. Or {@code null} to let the panel decide. */ void appendInt(int i, /* @Nullable */T color); /** * Mutates {@code this} by appending {@code f} to it. * * @param f * The float to append. * @param color * {@code text}'s color. Or {@code null} to let the panel decide. */ void appendFloat(float f, /* @Nullable */T color); /** * Mutates {@code this} by appending {@code other} to it. * * @param other */ void append(IColoredString<T> other); /** * Replace color {@code old} by {@code new_} in all buckets of {@code this}. * * @param old * The color to replace. * @param new_ * The replacing color. */ void replaceColor(/* @Nullable */ T old, /* @Nullable */ T new_); /** * Deletes all content after index {@code len} (if any). * * @param len */ void setLength(int len); /** * @return {@code true} if {@link #present()} is {@code ""}. */ boolean isEmpty(); /** * @param width * A positive integer * @return {@code this} split in pieces that would fit in a display with * {@code width} columns (if all words in {@code this} are smaller * or equal in length to {@code width}, otherwise wrapping will fail * for these words). */ List<IColoredString<T>> wrap(int width); /** * This method does NOT guarantee that the result's length is {@code width}. * It is impossible to do correct justifying if {@code this}'s length is * greater than {@code width} or if {@code this} has no space character. * * @param width * @return A variant of {@code this} where spaces have been introduced * in-between words, so that {@code this}'s length is as close as * possible to {@code width}. Or {@code this} itself if unaffected. */ IColoredString<T> justify(int width); /** * Empties {@code this}. */ void clear(); /** * This method is typically more efficient than {@link #colorAt(int)}. * * @return The color of the last bucket, if any. */ /* @Nullable */ T lastColor(); /** * @param index * @return The color at {@code index}, if any. * @throws NoSuchElementException * If {@code index} equals or is greater to {@link #length()}. */ /* @Nullable */ T colorAt(int index); /** * @return The length of text. */ int length(); /** * @return The text that {@code this} represents. */ String present(); /** * Given some way of converting from a T value to an in-line markup tag, returns a string representation of * this IColoredString with in-line markup representing colors. * @param markup an IMarkup implementation * @return a String with markup inserted inside. */ String presentWithMarkup(IMarkup<T> markup); /** * A basic implementation of {@link IColoredString}. * * @author smelC * * @param <T> * The type of colors */ class Impl<T> implements IColoredString<T> { protected final LinkedList<Bucket<T>> fragments; /** * An empty instance. */ public Impl() { fragments = new LinkedList<>(); } /** * An instance initially containing {@code text} (with {@code color}). * * @param text * The text that {@code this} should contain. * @param color * The color of {@code text}. */ public Impl(String text, /* @Nullable */T color) { this(); append(text, color); } /** * A static constructor, to avoid having to write {@code <T>} in the * caller. * * @return {@code new Impl(s, t)}. */ public static <T> IColoredString.Impl<T> create() { return new IColoredString.Impl<>("", null); } /** * A convenience method, equivalent to {@code create(s, null)}. * * @param s * @return {@code create(s, null)} */ public static <T> IColoredString.Impl<T> create(String s) { return create(s, null); } /** * A static constructor, to avoid having to write {@code <T>} in the * caller. * * @return {@code new Impl(s, t)}. */ public static <T> IColoredString.Impl<T> create(String s, /* @Nullable */ T t) { return new IColoredString.Impl<>(s, t); } public static <T> IColoredString.Impl<T> clone(IColoredString<T> toClone) { final IColoredString.Impl<T> result = new IColoredString.Impl<T>(); result.append(toClone); return result; } /** * @param one * @param two * @return Whether {@code one} represents the same content as * {@code two}. */ /* * Method could be smarter, i.e. return true more often, by doing some * normalization. It is unnecessary if you only create instances of * IColoredString.Impl. */ public static <T> boolean equals(IColoredString<T> one, IColoredString<T> two) { if (one == two) return true; final Iterator<IColoredString.Bucket<T>> oneIt = one.iterator(); final Iterator<IColoredString.Bucket<T>> twoIt = two.iterator(); while (true) { if (oneIt.hasNext()) { if (twoIt.hasNext()) { final Bucket<T> oneb = oneIt.next(); final Bucket<T> twob = twoIt.next(); if (!equals(oneb.getText(), twob.getText())) return false; if (!equals(oneb.getColor(), twob.getColor())) return false; continue; } else /* 'this' not terminated, but 'other' is. */ return false; } else { if (twoIt.hasNext()) /* 'this' terminated, but not 'other'. */ return false; else /* Both terminated */ break; } } return true; } @Override public void append(char c) { append(c, null); } @Override public void append(char c, T color) { append(String.valueOf(c), color); } @Override public void append(String text) { append(text, null); } @Override public void append(String text, T color) { if (text == null || text.isEmpty()) return; if (fragments.isEmpty()) fragments.add(new Bucket<>(text, color)); else { final Bucket<T> last = fragments.getLast(); if (equals(last.color, color)) { /* Append to the last bucket, to avoid extending the list */ final Bucket<T> novel = last.append(text); fragments.removeLast(); fragments.addLast(novel); } else fragments.add(new Bucket<>(text, color)); } } @Override public void appendInt(int i, T color) { append(String.valueOf(i), color); } @Override public void appendFloat(float f, T color) { final int i = Math.round(f); append(i == f ? String.valueOf(i) : String.valueOf(f), color); } @Override /* KISS implementation */ public void append(IColoredString<T> other) { for (IColoredString.Bucket<T> ofragment : other) append(ofragment.getText(), ofragment.getColor()); } @Override public void replaceColor(/* @Nullable */ T old, /* @Nullable */ T new_) { if (equals(old, new_)) /* Nothing to do */ return; final ListIterator<Bucket<T>> it = fragments.listIterator(); while (it.hasNext()) { final Bucket<T> bucket = it.next(); if (equals(bucket.color, old)) { /* Replace */ it.remove(); it.add(new Bucket<T>(bucket.getText(), new_)); } /* else leave untouched */ } } public void append(Bucket<T> bucket) { this.fragments.add(new Bucket<>(bucket.getText(), bucket.getColor())); } @Override public void setLength(int len) { int l = 0; final ListIterator<IColoredString.Bucket<T>> it = fragments.listIterator(); while (it.hasNext()) { final IColoredString.Bucket<T> next = it.next(); final String ftext = next.text; final int flen = ftext.length(); final int nextl = l + flen; if (nextl < len) { /* Nothing to do */ l += flen; continue; } else if (nextl == len) { /* Delete all next fragments */ while (it.hasNext()) { it.next(); it.remove(); } /* We'll exit the outer loop right away */ } else { assert len < nextl; /* Trim this fragment */ final IColoredString.Bucket<T> trimmed = next.setLength(len - l); /* Replace this fragment */ it.remove(); it.add(trimmed); /* Delete all next fragments */ while (it.hasNext()) { it.next(); it.remove(); } /* We'll exit the outer loop right away */ } } } @Override public List<IColoredString<T>> wrap(int width) { if (width <= 0) { /* Really, you should not rely on this behavior */ System.err.println("Cannot wrap string in empty display"); final List<IColoredString<T>> result = new LinkedList<>(); result.add(this); return result; } final List<IColoredString<T>> result = new ArrayList<>(); if (isEmpty()) { /* * Catch this case early on, as empty lines are eaten below (see * code after the while). Checking emptyness is cheap anyway. */ result.add(this); return result; } IColoredString<T> current = create(); int curlen = 0; final Iterator<Bucket<T>> it = iterator(); while (it.hasNext()) { final Bucket<T> next = it.next(); final String bucket = next.getText(); final String[] split = bucket.split(" "); final T color = next.color; for (int i = 0; i < split.length; i++) { if (i == split.length - 1 && bucket.endsWith(" ")) /* * Do not loose trailing space that got eaten by * 'bucket.split'. */ split[i] = split[i] + " "; final String chunk = split[i]; final int chunklen = chunk.length(); final boolean addLeadingSpace = 0 < curlen && 0 < i; if (curlen + chunklen + (addLeadingSpace ? 1 : 0) <= width) { if (addLeadingSpace) { /* * Do not forget space on which chunk got split. If * the space is offscreen, it's harmless, hence not * checking it. */ current.append(' ', null); curlen++; } /* Can add it */ current.append(chunk, color); /* Extend size */ curlen += chunklen; } else { /* Need to wrap */ /* Flush content so far */ if (!current.isEmpty()) result.add(current); /* * else: line was prepared, but did not contain anything */ if (chunklen <= width) { curlen = chunklen; current = create(); current.append(chunk, color); /* Reinit size */ curlen = chunklen; } else { /* * This word is too long. Adding it and preparing a * new line immediately. */ /* Add */ result.add(new Impl<>(chunk, color)); /* Prepare for next rolls */ current = create(); /* Reinit size */ curlen = 0; } } } } if (!current.isEmpty()) { /* Flush rest */ result.add(current); } return result; } @Override /* * smelC: not the cutest result (we should add spaces both from the left * and the right, instead of just from the left), but better than * nothing. */ public IColoredString<T> justify(int width) { int length = length(); if (width <= length) /* * If width==length, we're good. If width<length, we cannot * adjust */ return this; int totalDiff = width - length; assert 0 < totalDiff; if (width <= totalDiff * 3) /* Too much of a difference, it would look very weird. */ return this; final IColoredString.Impl<T> result = create(); ListIterator<IColoredString.Bucket<T>> it = fragments.listIterator(); final int nbb = fragments.size(); final int[] bucketToNbSpaces = new int[nbb]; /* The number of buckets that can contribute to justifying */ int totalNbSpaces = 0; /* The index of the last bucket that has spaces */ int lastHopeIndex = -1; { int i = 0; while (it.hasNext()) { final Bucket<T> next = it.next(); final int nbs = nbSpaces(next.getText()); totalNbSpaces += nbs; bucketToNbSpaces[i] = nbs; i++; } if (totalNbSpaces == 0) /* Cannot do anything */ return this; for (int j = bucketToNbSpaces.length - 1; 0 <= j; j--) { if (0 < bucketToNbSpaces[j]) { lastHopeIndex = j; break; } } /* Holds because we ruled out 'totalNbSpaces == 0' before */ assert 0 <= lastHopeIndex; } int toAddPerSpace = totalNbSpaces == 0 ? 0 : (totalDiff / totalNbSpaces); int totalRest = totalDiff - (toAddPerSpace * totalNbSpaces); assert 0 <= totalRest; int bidx = -1; it = fragments.listIterator(); while (it.hasNext() && 0 < totalDiff) { bidx++; final Bucket<T> next = it.next(); final String bucket = next.getText(); final int blength = bucket.length(); final int localNbSpaces = bucketToNbSpaces[bidx]; if (localNbSpaces == 0) { /* Cannot change it */ result.append(next); continue; } int localDiff = localNbSpaces * toAddPerSpace; assert localDiff <= totalDiff; int nb = localDiff / localNbSpaces; int localRest = localDiff - (nb * localNbSpaces); if (localRest == 0 && 0 < totalRest) { /* * Take one for the group. This avoids flushing all spaces * needed in the 'last hope' cases below. */ localRest = 1; } assert 0 <= localRest; assert localRest <= totalRest; String novel = ""; int eatenSpaces = 1; for (int i = 0; i < blength; i++) { final char c = bucket.charAt(i); novel += c; if (c == ' ' && (0 < localDiff || 0 < totalDiff || 0 < localRest || 0 < totalRest)) { /* Can (and should) add an extra space */ for (int j = 0; j < nb && 0 < localDiff; j++) { novel += " "; localDiff--; totalDiff--; } if (0 < localRest || 0 < totalRest) { if (eatenSpaces == localNbSpaces) { /* I'm the last hope for this bucket */ for (int j = 0; j < localRest; j++) { novel += " "; localRest--; totalRest--; } if (bidx == lastHopeIndex) { /* I'm the last hope globally */ while (0 < totalRest) { novel += " "; totalRest--; } } } else { if (0 < localRest && 0 < totalRest) { /* Not the last hope: take one only */ novel += " "; localRest--; totalRest--; } } } eatenSpaces++; } } /* I did my job */ assert localRest == 0; /* If I was the hope, I did my job */ assert bidx != lastHopeIndex || totalRest == 0; result.append(novel, next.getColor()); } while (it.hasNext()) result.append(it.next()); return result; } @Override public void clear() { fragments.clear(); } @Override public int length() { int result = 0; for (Bucket<T> fragment : fragments) result += fragment.getText().length(); return result; } @Override /* This implementation is resilient to empty buckets */ public boolean isEmpty() { for (Bucket<?> bucket : fragments) { if (bucket.text == null || bucket.text.isEmpty()) continue; else return false; } return true; } @Override public T lastColor() { return fragments.isEmpty() ? null : fragments.getLast().color; } @Override public T colorAt(int index) { final ListIterator<IColoredString.Bucket<T>> it = fragments.listIterator(); int now = 0; while (it.hasNext()) { final IColoredString.Bucket<T> next = it.next(); final String ftext = next.text; final int flen = ftext.length(); final int nextl = now + flen; if (index <= nextl) return next.color; now += flen; } throw new NoSuchElementException("Character at index " + index + " in " + this); } @Override public String present() { final StringBuilder result = new StringBuilder(); for (Bucket<T> fragment : fragments) result.append(fragment.text); return result.toString(); } /** * Given some way of converting from a T value to an in-line markup tag, returns a string representation of * this IColoredString with in-line markup representing colors. * @param markup an IMarkup implementation * @return a String with markup inserted inside. */ @Override public String presentWithMarkup(IMarkup<T> markup) { final StringBuilder result = new StringBuilder(); boolean open = false; for (Bucket<T> fragment : fragments) { if(fragment.color != null) { if (open) result.append(markup.closeMarkup()); result.append(markup.getMarkup(fragment.color)); open = true; } else { if (open) result.append(markup.closeMarkup()); open = false; } result.append(fragment.text); } return result.toString(); } @Override public Iterator<Bucket<T>> iterator() { return fragments.iterator(); } @Override public String toString() { return present(); } protected static boolean equals(Object o1, Object o2) { if (o1 == null) return o2 == null; else return o1.equals(o2); } private int nbSpaces(String s) { final int bd = s.length(); int result = 0; for (int i = 0; i < bd; i++) { final char c = s.charAt(i); if (c == ' ') result++; } return result; } } /** * A piece of a {@link IColoredString}: a text and its color. * * @author smelC * * @param <T> * The type of colors; */ class Bucket<T> { protected final String text; protected final/* @Nullable */T color; public Bucket(String text, /* @Nullable */T color) { this.text = text == null ? "" : text; this.color = color; } /** * @param text * @return An instance whose text is {@code this.text + text}. Color is * unchanged. */ public Bucket<T> append(String text) { if (text == null || text.isEmpty()) /* Let's save an allocation */ return this; else return new Bucket<>(this.text + text, color); } public Bucket<T> setLength(int l) { final int here = text.length(); if (here <= l) return this; else return new Bucket<>(text.substring(0, l), color); } /** * @return The text that this bucket contains. */ public String getText() { return text; } /** * @return The color of {@link #getText()}. Or {@code null} if none. */ public/* @Nullable */T getColor() { return color; } @Override public String toString() { if (color == null) return text; else return text + "(" + color + ")"; } } }